google-ads-api 14.2.0 → 16.0.0-rest-beta0

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
@@ -7,7 +7,7 @@
7
7
  </p>
8
8
  <p align="center">
9
9
  <a href="https://developers.google.com/google-ads/api/docs/release-notes">
10
- <img src="https://img.shields.io/badge/google%20ads-v14.1.0-009688.svg?style=flat-square">
10
+ <img src="https://img.shields.io/badge/google%20ads-v16.1.0-009688.svg?style=flat-square">
11
11
  </a>
12
12
  <a href="https://www.npmjs.com/package/google-ads-api">
13
13
  <img src="https://img.shields.io/npm/v/google-ads-api.svg?style=flat-square">
@@ -384,7 +384,7 @@ const adGroupResourceName = 'customers/123/adGroups/456'
384
384
  const keyword = '24 hour locksmith harlem'
385
385
 
386
386
  const operations: MutateOperation<
387
- resources.IAdGroupCriterion & { exempt_policy_violation_keys?: google.ads.googleads.v14.common.IPolicyViolationKey[]}
387
+ resources.IAdGroupCriterion & { exempt_policy_violation_keys?: google.ads.googleads.v16.common.IPolicyViolationKey[]}
388
388
  >[] = [
389
389
  {
390
390
  entity: 'ad_group_criterion',
@@ -7,6 +7,7 @@ export interface ClientOptions {
7
7
  client_secret: string;
8
8
  developer_token: string;
9
9
  disable_parsing?: boolean;
10
+ max_reporting_rows?: number;
10
11
  }
11
12
  export declare class Client {
12
13
  private readonly options;
@@ -4,6 +4,21 @@ import { Hooks } from "./hooks";
4
4
  import { services } from "./protos";
5
5
  import ServiceFactory from "./protos/autogen/serviceFactory";
6
6
  import { CustomerOptions, MutateOperation, MutateOptions, ReportOptions, RequestOptions } from "./types";
7
+ /**
8
+ * TODO:
9
+ * - rename querierRest to streamRest. Add hooks support to it. OK
10
+ * - write querierRest, using pagination. OK
11
+ * - make sure return_total_results_count works
12
+ * - make sure SUMMARY_ROW_SETTING works
13
+ * - adapt query()
14
+ * - adapt queryStream
15
+ * - adapt report()
16
+ * - adapt reportCount()
17
+ * - adapt reportStream()
18
+ * - reportStreamRaw() ?
19
+ * - bonus: use queryStream when the params allow it. Note: do it with native async generator to make it as fast as possible.
20
+ * - bonus: make it possible to cancel a stream or pagination when row count exceeds specified max
21
+ */
7
22
  export declare class Customer extends ServiceFactory {
8
23
  constructor(clientOptions: ClientOptions, customerOptions: CustomerOptions, hooks?: Hooks);
9
24
  /**
@@ -52,8 +67,10 @@ export declare class Customer extends ServiceFactory {
52
67
  reportStreamRaw(reportOptions: Readonly<ReportOptions>): Promise<CancellableStream | void>;
53
68
  private search;
54
69
  private paginatedSearch;
70
+ private useStreamToImitateRegularSearch;
55
71
  private querier;
56
72
  private streamer;
73
+ private handleStreamError;
57
74
  /**
58
75
  * @description Creates, updates, or removes resources. This method supports atomic transactions
59
76
  * with multiple types of resources. For example, you can atomically create a campaign and a
@@ -62,4 +79,8 @@ export declare class Customer extends ServiceFactory {
62
79
  */
63
80
  mutateResources<T>(mutations: MutateOperation<T>[], mutateOptions?: MutateOptions): Promise<services.MutateGoogleAdsResponse>;
64
81
  private get googleAdsFields();
82
+ private prepareGoogleAdsServicePostRequestArgs;
83
+ private decamelizeKeysIfNeeded;
84
+ private gaqlQueryStringIncludesLimit;
85
+ private generateTooManyRowsError;
65
86
  }
@@ -4,10 +4,31 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Customer = void 0;
7
- const parser_1 = require("./parser");
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const stream_chain_1 = require("stream-chain");
9
+ const stream_json_1 = require("stream-json");
10
+ const StreamArray_1 = require("stream-json/streamers/StreamArray");
11
+ const parserRest_1 = require("./parserRest");
12
+ const protos_1 = require("./protos");
8
13
  const serviceFactory_1 = __importDefault(require("./protos/autogen/serviceFactory"));
9
14
  const query_1 = require("./query");
10
- const utils_1 = require("./utils");
15
+ const version_1 = require("./version");
16
+ const ROWS_PER_STREAMED_CHUNK = 10000; // From experience, this is what can be expected from the API.
17
+ /**
18
+ * TODO:
19
+ * - rename querierRest to streamRest. Add hooks support to it. OK
20
+ * - write querierRest, using pagination. OK
21
+ * - make sure return_total_results_count works
22
+ * - make sure SUMMARY_ROW_SETTING works
23
+ * - adapt query()
24
+ * - adapt queryStream
25
+ * - adapt report()
26
+ * - adapt reportCount()
27
+ * - adapt reportStream()
28
+ * - reportStreamRaw() ?
29
+ * - bonus: use queryStream when the params allow it. Note: do it with native async generator to make it as fast as possible.
30
+ * - bonus: make it possible to cancel a stream or pagination when row count exceeds specified max
31
+ */
11
32
  class Customer extends serviceFactory_1.default {
12
33
  constructor(clientOptions, customerOptions, hooks) {
13
34
  super(clientOptions, customerOptions, hooks !== null && hooks !== void 0 ? hooks : {});
@@ -111,39 +132,119 @@ class Customer extends serviceFactory_1.default {
111
132
  });
112
133
  }
113
134
  async search(gaqlQuery, requestOptions) {
114
- const { service, request } = this.buildSearchRequestAndService(gaqlQuery, requestOptions);
115
- const searchResponse = await service.search(request, {
116
- otherArgs: { headers: this.callHeaders },
117
- autoPaginate: false, // autoPaginate doesn't work
118
- });
119
- const response = searchResponse[0];
120
- const summaryRow = searchResponse[2].summary_row;
121
- const nextPageToken = searchResponse[2].next_page_token;
122
- const totalResultsCount = searchResponse[2].total_results_count
123
- ? +searchResponse[2].total_results_count
124
- : undefined;
125
- if (summaryRow) {
126
- response.unshift(summaryRow);
135
+ var _a, _b;
136
+ const accessToken = await this.getAccessToken();
137
+ try {
138
+ const rawResponse = await (0, axios_1.default)(this.prepareGoogleAdsServicePostRequestArgs("search", accessToken, {
139
+ data: {
140
+ query: gaqlQuery,
141
+ ...requestOptions,
142
+ },
143
+ }));
144
+ const searchResponse = rawResponse.data;
145
+ const results = (_a = searchResponse.results) !== null && _a !== void 0 ? _a : [];
146
+ const response = results.map((row) => this.decamelizeKeysIfNeeded(row));
147
+ const summaryRow = this.decamelizeKeysIfNeeded(searchResponse.summaryRow);
148
+ const nextPageToken = searchResponse.nextPageToken;
149
+ const totalResultsCount = searchResponse.totalResultsCount
150
+ ? +searchResponse.totalResultsCount
151
+ : undefined;
152
+ return { response, nextPageToken, totalResultsCount, summaryRow };
153
+ }
154
+ catch (e) {
155
+ if ((_b = e.response) === null || _b === void 0 ? void 0 : _b.data.error.details[0]) {
156
+ throw new protos_1.errors.GoogleAdsFailure(this.decamelizeKeysIfNeeded(e.response.data.error.details[0]));
157
+ }
158
+ throw e;
127
159
  }
128
- return { response, nextPageToken, totalResultsCount };
129
160
  }
130
- async paginatedSearch(gaqlQuery, requestOptions, parser) {
161
+ async paginatedSearch(gaqlQuery, requestOptions) {
162
+ /*
163
+ When possible, use the searchStream method to avoid the overhead of pagination.
164
+ */
165
+ if (requestOptions.page_size === undefined &&
166
+ // @ts-expect-error we do not allow this field in reportOptions, however it is still a valid request option
167
+ requestOptions.return_total_results_count === undefined) {
168
+ // If no pagination or summary options are set, we can use the non-paginated search method.
169
+ const { response } = await this.useStreamToImitateRegularSearch(gaqlQuery, requestOptions);
170
+ return { response };
171
+ }
131
172
  const response = [];
132
173
  let nextPageToken = undefined;
133
174
  const initialSearch = await this.search(gaqlQuery, requestOptions);
134
175
  const totalResultsCount = initialSearch.totalResultsCount;
135
- response.push(...parser(initialSearch.response));
176
+ let summaryRow = initialSearch.summaryRow;
177
+ response.push(...initialSearch.response);
136
178
  nextPageToken = initialSearch.nextPageToken;
137
179
  while (nextPageToken) {
138
180
  const nextSearch = await this.search(gaqlQuery, {
139
181
  ...requestOptions,
140
182
  page_token: nextPageToken,
141
183
  });
142
- response.push(...parser(nextSearch.response));
184
+ response.push(...nextSearch.response);
143
185
  nextPageToken = nextSearch.nextPageToken;
186
+ if (nextSearch.summaryRow) {
187
+ summaryRow = nextSearch.summaryRow;
188
+ }
189
+ }
190
+ if (summaryRow) {
191
+ response.unshift(summaryRow);
144
192
  }
145
193
  return { response, totalResultsCount };
146
194
  }
195
+ // Google's searchStream method is faster than search, but it does not support all features.
196
+ // When report() is called, we use searchStream if possible, otherwise we use paginatedSearch.
197
+ // Note that just like `paginatedSearch`, this method accumulates results in memory. Use
198
+ // `reportStream` for a more memory-efficient alternative (at the cost of more CPU usage).
199
+ async useStreamToImitateRegularSearch(gaqlQuery, requestOptions) {
200
+ const accessToken = await this.getAccessToken();
201
+ try {
202
+ const args = this.prepareGoogleAdsServicePostRequestArgs("searchStream", accessToken, {
203
+ responseType: "stream",
204
+ data: {
205
+ query: gaqlQuery,
206
+ ...requestOptions,
207
+ },
208
+ });
209
+ const response = await (0, axios_1.default)(args);
210
+ const stream = response.data;
211
+ const buffers = [];
212
+ let rowCount = -ROWS_PER_STREAMED_CHUNK;
213
+ for await (const data of stream) {
214
+ if (this.clientOptions.max_reporting_rows &&
215
+ !this.gaqlQueryStringIncludesLimit(gaqlQuery)) {
216
+ // This is a quick-and-dirty way to count rows, but it's good enough for our purposes.
217
+ // We want to avoid using a proper JSON streamer here for performance reasons.
218
+ if (data.toString("utf-8").includes(`results":`)) {
219
+ rowCount += ROWS_PER_STREAMED_CHUNK;
220
+ }
221
+ if (rowCount > this.clientOptions.max_reporting_rows) {
222
+ throw this.generateTooManyRowsError();
223
+ }
224
+ }
225
+ buffers.push(data);
226
+ }
227
+ const asString = Buffer.concat(buffers).toString("utf-8");
228
+ const accumulator = [];
229
+ let foundSummaryRow;
230
+ for (const { results, summaryRow } of JSON.parse(asString)) {
231
+ if (summaryRow) {
232
+ foundSummaryRow = this.decamelizeKeysIfNeeded(summaryRow);
233
+ }
234
+ accumulator.push(...(results !== null && results !== void 0 ? results : []).map((row) => {
235
+ return this.decamelizeKeysIfNeeded(row);
236
+ }));
237
+ if (foundSummaryRow) {
238
+ accumulator.unshift(foundSummaryRow);
239
+ }
240
+ }
241
+ return { response: accumulator };
242
+ }
243
+ catch (e) {
244
+ await this.handleStreamError(e);
245
+ throw e; // The line above should always throw.
246
+ }
247
+ }
147
248
  async querier(gaqlQuery, requestOptions = {}, reportOptions, useHooks = true) {
148
249
  const baseHookArguments = {
149
250
  credentials: this.credentials,
@@ -170,14 +271,7 @@ class Customer extends serviceFactory_1.default {
170
271
  }
171
272
  }
172
273
  try {
173
- const parsingWapper = (rows) => {
174
- return this.clientOptions.disable_parsing
175
- ? rows
176
- : reportOptions
177
- ? (0, parser_1.parse)({ results: rows, reportOptions })
178
- : (0, parser_1.parse)({ results: rows, gaqlString: gaqlQuery });
179
- };
180
- const { response, totalResultsCount } = await this.paginatedSearch(gaqlQuery, requestOptions, parsingWapper);
274
+ const { response, totalResultsCount } = await this.paginatedSearch(gaqlQuery, requestOptions);
181
275
  if (this.hooks.onQueryEnd && useHooks) {
182
276
  const queryResolution = { resolved: false };
183
277
  await this.hooks.onQueryEnd({
@@ -206,6 +300,7 @@ class Customer extends serviceFactory_1.default {
206
300
  }
207
301
  }
208
302
  async *streamer(gaqlQuery, requestOptions = {}, reportOptions) {
303
+ var _a;
209
304
  const baseHookArguments = {
210
305
  credentials: this.credentials,
211
306
  query: gaqlQuery,
@@ -229,58 +324,74 @@ class Customer extends serviceFactory_1.default {
229
324
  return;
230
325
  }
231
326
  }
232
- const { service, request } = this.buildSearchStreamRequestAndService(gaqlQuery, requestOptions);
233
- const stream = service.searchStream(request, {
234
- otherArgs: { headers: this.callHeaders },
235
- });
236
- let streamFinished = false;
237
- const accumulator = [];
238
- let nextChunk = (0, utils_1.createNextChunkArrivedPromise)();
239
- stream.on("data", (chunk) => {
240
- const results = chunk.summary_row ? [chunk.summary_row] : chunk.results;
241
- const parsedResponse = this.clientOptions.disable_parsing
242
- ? results
243
- : reportOptions
244
- ? (0, parser_1.parse)({ results, reportOptions })
245
- : (0, parser_1.parse)({ results, gaqlString: gaqlQuery });
246
- accumulator.push(...parsedResponse);
247
- nextChunk.resolve();
248
- nextChunk = (0, utils_1.createNextChunkArrivedPromise)();
249
- });
250
- stream.on("error", (searchError) => {
251
- nextChunk.reject(searchError);
252
- });
253
- stream.on("end", () => {
254
- streamFinished = true;
255
- nextChunk.resolve();
256
- });
257
327
  try {
258
- while (!streamFinished || accumulator.length) {
259
- if (accumulator.length > 0) {
260
- const item = accumulator.shift();
261
- if (item === undefined) {
262
- throw new Error("UNDEFINED_STREAM_ERROR");
263
- }
264
- yield item;
328
+ const accessToken = await this.getAccessToken();
329
+ const args = this.prepareGoogleAdsServicePostRequestArgs("searchStream", accessToken, {
330
+ responseType: "stream",
331
+ data: {
332
+ query: gaqlQuery,
333
+ ...requestOptions,
334
+ },
335
+ });
336
+ const response = await (0, axios_1.default)(args);
337
+ const stream = response.data;
338
+ // The options below help to make the stream less CPU intensive.
339
+ const parser = new stream_json_1.Parser({
340
+ streamValues: false,
341
+ streamKeys: false,
342
+ packValues: true,
343
+ packKeys: true,
344
+ });
345
+ const pipeline = (0, stream_chain_1.chain)([stream, parser, (0, StreamArray_1.streamArray)()]);
346
+ let count = 0;
347
+ for await (const data of pipeline) {
348
+ const results = (_a = data.value.results) !== null && _a !== void 0 ? _a : [data.value.summaryRow];
349
+ count += results.length;
350
+ if (this.clientOptions.max_reporting_rows &&
351
+ count > this.clientOptions.max_reporting_rows &&
352
+ !this.gaqlQueryStringIncludesLimit(gaqlQuery)) {
353
+ throw this.generateTooManyRowsError();
265
354
  }
266
- else {
267
- await nextChunk.newPromise;
355
+ for (const row of results) {
356
+ const parsed = this.decamelizeKeysIfNeeded(row);
357
+ yield parsed;
268
358
  }
269
359
  }
360
+ return;
270
361
  }
271
- catch (searchError) {
272
- const googleAdsError = this.getGoogleAdsError(searchError);
273
- if (this.hooks.onStreamError) {
274
- await this.hooks.onStreamError({
275
- ...baseHookArguments,
276
- error: googleAdsError,
277
- });
362
+ catch (e) {
363
+ try {
364
+ await this.handleStreamError(e);
365
+ }
366
+ catch (_e) {
367
+ if (this.hooks.onStreamError) {
368
+ await this.hooks.onStreamError({
369
+ ...baseHookArguments,
370
+ error: _e,
371
+ });
372
+ }
373
+ throw _e;
278
374
  }
279
- throw googleAdsError;
280
375
  }
281
- finally {
282
- stream.destroy();
376
+ }
377
+ async handleStreamError(e) {
378
+ var _a;
379
+ if (!((_a = e === null || e === void 0 ? void 0 : e.response) === null || _a === void 0 ? void 0 : _a.data)) {
380
+ throw e;
283
381
  }
382
+ // The error is a stream, so some effort is required to parse it.
383
+ const stream = e.response.data;
384
+ const pipeline = (0, stream_chain_1.chain)([stream, (0, stream_json_1.parser)(), (0, StreamArray_1.streamArray)()]);
385
+ let googleAdsFailure;
386
+ // Only throw the first error.
387
+ pipeline.once("data", (data) => {
388
+ googleAdsFailure = new protos_1.errors.GoogleAdsFailure(this.decamelizeKeysIfNeeded(data.value.error.details[0]));
389
+ });
390
+ // Must always reject.
391
+ await new Promise((_, reject) => {
392
+ pipeline.on("end", () => reject(googleAdsFailure));
393
+ pipeline.on("error", (err) => reject(err));
394
+ });
284
395
  }
285
396
  /**
286
397
  * @description Creates, updates, or removes resources. This method supports atomic transactions
@@ -360,5 +471,31 @@ class Customer extends serviceFactory_1.default {
360
471
  },
361
472
  };
362
473
  }
474
+ prepareGoogleAdsServicePostRequestArgs(functionName, accessToken, extra) {
475
+ var _a, _b;
476
+ return {
477
+ method: "POST",
478
+ url: `https://googleads.googleapis.com/${version_1.googleAdsVersion}/customers/${this.customerOptions.customer_id}/googleAds:${functionName}`,
479
+ headers: {
480
+ Authorization: `Bearer ${accessToken}`,
481
+ ...this.callHeaders,
482
+ "developer-token": (_a = this.clientOptions.developer_token) !== null && _a !== void 0 ? _a : "",
483
+ "login-customer-id": (_b = this.credentials.login_customer_id) !== null && _b !== void 0 ? _b : "",
484
+ },
485
+ ...extra,
486
+ };
487
+ }
488
+ decamelizeKeysIfNeeded(input) {
489
+ if (this.clientOptions.disable_parsing) {
490
+ return input;
491
+ }
492
+ return (0, parserRest_1.decamelizeKeys)(input);
493
+ }
494
+ gaqlQueryStringIncludesLimit(gaqlQuery) {
495
+ return gaqlQuery.toLowerCase().includes("limit ");
496
+ }
497
+ generateTooManyRowsError() {
498
+ return new Error(`Exceeded the maximum number of rows set by "max_reporting_rows" (${this.clientOptions.max_reporting_rows}).`);
499
+ }
363
500
  }
364
501
  exports.Customer = Customer;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * JSON Rest parsing
3
+ */
4
+ export declare const decamelizeKeys: (input: any) => any;
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ /**
3
+ * JSON Rest parsing
4
+ */
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.decamelizeKeys = void 0;
10
+ const map_obj_1 = __importDefault(require("map-obj"));
11
+ const circ_json_1 = require("circ-json");
12
+ const utils_1 = require("./utils");
13
+ const fields_1 = require("./protos/autogen/fields");
14
+ const enums_1 = require("./protos/autogen/enums");
15
+ const fieldDataTypes = (0, circ_json_1.parse)(fields_1.fieldDataTypes);
16
+ const decamelizeCache = new Map();
17
+ const fieldTypeCache = new Map();
18
+ const isObject = (value) => typeof value === "object" && value !== null;
19
+ const decamelizeKeys = (input) => {
20
+ if (!isObject(input)) {
21
+ return input;
22
+ }
23
+ const makeMapper = (parentPath) => (key, value) => {
24
+ key = cachedDecamelize(key);
25
+ if (isObject(value)) {
26
+ const path = parentPath === undefined ? key : `${parentPath}.${key}`;
27
+ // @ts-ignore
28
+ value = (0, map_obj_1.default)(value, makeMapper(path));
29
+ }
30
+ else {
31
+ value = cachedValueParser(key, parentPath, value);
32
+ }
33
+ return [key, value];
34
+ };
35
+ // @ts-ignore
36
+ return (0, map_obj_1.default)(input, makeMapper());
37
+ };
38
+ exports.decamelizeKeys = decamelizeKeys;
39
+ const cachedDecamelize = (key) => {
40
+ const cachedResult = decamelizeCache.get(key);
41
+ if (cachedResult) {
42
+ return cachedResult;
43
+ }
44
+ const newKey = (0, utils_1.toSnakeCase)(key);
45
+ decamelizeCache.set(key, newKey);
46
+ return newKey;
47
+ };
48
+ const cachedValueParser = (key, parentPath, value) => {
49
+ let newValue = value;
50
+ const fullPath = parentPath ? `${parentPath}.${key}` : key;
51
+ const megaDataType = getTypeFromPath(fullPath);
52
+ if (megaDataType === undefined && !fullPath.startsWith("@")) {
53
+ console.warn(`No data type found for ${fullPath}`);
54
+ }
55
+ else if (typeof megaDataType === "object") {
56
+ newValue = megaDataType[value];
57
+ }
58
+ else if (megaDataType === "INT64") {
59
+ newValue = Number(value);
60
+ }
61
+ else if (megaDataType === "ENUM") {
62
+ // Some enums aren't embedded in megaDataType, so we need this fallback.
63
+ // @ts-expect-error typescript doesn't like accessing items in a namespace with a string
64
+ newValue = enums_1.enums[fields_1.fields.enumFields[fullPath]][value]; // e.g. enums['CampaignStatus'][ENABLED] = "2"
65
+ }
66
+ return newValue;
67
+ };
68
+ const getTypeFromPath = (path) => {
69
+ const cachedResult = fieldTypeCache.get(path);
70
+ if (cachedResult) {
71
+ return cachedResult;
72
+ }
73
+ const t = get(fieldDataTypes, path);
74
+ fieldTypeCache.set(path, t);
75
+ return t;
76
+ };
77
+ // Copied from youmightnotneed.com
78
+ const get = (obj, path) => {
79
+ if (!path)
80
+ return undefined;
81
+ // Check if path is string or array. Regex : ensure that we do not have '.' and brackets.
82
+ // Regex explained: https://regexr.com/58j0k
83
+ const pathArray = path.match(/([^[.\]])+/g);
84
+ if (!pathArray)
85
+ return undefined;
86
+ // Find value
87
+ return pathArray.reduce((prevObj, key) => prevObj && prevObj[key], obj);
88
+ };