google-ads-api 19.0.2 → 19.0.3-rest-beta

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.
@@ -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,7 @@ exports.Client = void 0;
4
4
  const customer_1 = require("./customer");
5
5
  const service_1 = require("./service");
6
6
  class Client {
7
+ options;
7
8
  constructor(options) {
8
9
  this.options = options;
9
10
  }
@@ -52,8 +52,10 @@ export declare class Customer extends ServiceFactory {
52
52
  reportStreamRaw(reportOptions: Readonly<ReportOptions>): Promise<CancellableStream | void>;
53
53
  private search;
54
54
  private paginatedSearch;
55
+ private useStreamToImitateRegularSearch;
55
56
  private querier;
56
57
  private streamer;
58
+ private handleStreamError;
57
59
  /**
58
60
  * @description Creates, updates, or removes resources. This method supports atomic transactions
59
61
  * with multiple types of resources. For example, you can atomically create a campaign and a
@@ -62,4 +64,8 @@ export declare class Customer extends ServiceFactory {
62
64
  */
63
65
  mutateResources<T>(mutations: MutateOperation<T>[], mutateOptions?: MutateOptions): Promise<services.MutateGoogleAdsResponse>;
64
66
  private get googleAdsFields();
67
+ private prepareGoogleAdsServicePostRequestArgs;
68
+ private decamelizeKeysIfNeeded;
69
+ private gaqlQueryStringIncludesLimit;
70
+ private generateTooManyRowsError;
65
71
  }
@@ -4,13 +4,19 @@ 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 = 10_000; // From experience, this is what can be expected from the API.
11
17
  class Customer extends serviceFactory_1.default {
12
18
  constructor(clientOptions, customerOptions, hooks) {
13
- super(clientOptions, customerOptions, hooks !== null && hooks !== void 0 ? hooks : {});
19
+ super(clientOptions, customerOptions, hooks ?? {});
14
20
  }
15
21
  /**
16
22
  @description Single query using a raw GAQL string.
@@ -112,39 +118,124 @@ class Customer extends serviceFactory_1.default {
112
118
  });
113
119
  }
114
120
  async search(gaqlQuery, requestOptions) {
115
- const { service, request } = this.buildSearchRequestAndService(gaqlQuery, requestOptions);
116
- const searchResponse = await service.search(request, {
117
- otherArgs: { headers: this.callHeaders },
118
- autoPaginate: false, // autoPaginate doesn't work
119
- });
120
- const response = searchResponse[0];
121
- const summaryRow = searchResponse[2].summary_row;
122
- const nextPageToken = searchResponse[2].next_page_token;
123
- const totalResultsCount = searchResponse[2].total_results_count
124
- ? +searchResponse[2].total_results_count
125
- : undefined;
126
- if (summaryRow) {
127
- response.unshift(summaryRow);
121
+ const accessToken = await this.getAccessToken();
122
+ try {
123
+ const rawResponse = await (0, axios_1.default)(this.prepareGoogleAdsServicePostRequestArgs("search", accessToken, {
124
+ data: {
125
+ query: gaqlQuery,
126
+ ...requestOptions,
127
+ },
128
+ }));
129
+ const searchResponse = rawResponse.data;
130
+ const results = searchResponse.results ?? [];
131
+ const response = results.map((row) => this.decamelizeKeysIfNeeded(row));
132
+ const summaryRow = this.decamelizeKeysIfNeeded(searchResponse.summaryRow);
133
+ const nextPageToken = searchResponse.nextPageToken;
134
+ const totalResultsCount = searchResponse.totalResultsCount
135
+ ? +searchResponse.totalResultsCount
136
+ : undefined;
137
+ return { response, nextPageToken, totalResultsCount, summaryRow };
138
+ }
139
+ catch (e) {
140
+ if (e.response?.data.error.details[0]) {
141
+ throw new protos_1.errors.GoogleAdsFailure(this.decamelizeKeysIfNeeded(e.response.data.error.details[0]));
142
+ }
143
+ throw e;
128
144
  }
129
- return { response, nextPageToken, totalResultsCount };
130
145
  }
131
- async paginatedSearch(gaqlQuery, requestOptions, parser) {
146
+ async paginatedSearch(gaqlQuery, requestOptions) {
147
+ /*
148
+ When possible, use the searchStream method to avoid the overhead of pagination.
149
+ */
150
+ if (requestOptions.page_size === undefined &&
151
+ requestOptions.search_settings === undefined // If search_settings is set, we can't use searchStream.
152
+ ) {
153
+ // If no pagination or summary options are set, we can use the non-paginated search method.
154
+ const { response } = await this.useStreamToImitateRegularSearch(gaqlQuery, requestOptions);
155
+ return { response };
156
+ }
132
157
  const response = [];
133
158
  let nextPageToken = undefined;
134
159
  const initialSearch = await this.search(gaqlQuery, requestOptions);
135
- const totalResultsCount = initialSearch.totalResultsCount;
136
- response.push(...parser(initialSearch.response));
160
+ let totalResultsCount = initialSearch.totalResultsCount;
161
+ // Sometimes (when no results?) the totalResultsCount field is not included in the response.
162
+ // In this case, we set it to 0.
163
+ if (requestOptions.search_settings?.return_total_results_count &&
164
+ initialSearch.totalResultsCount === undefined) {
165
+ totalResultsCount = 0;
166
+ }
167
+ let summaryRow = initialSearch.summaryRow;
168
+ response.push(...initialSearch.response);
137
169
  nextPageToken = initialSearch.nextPageToken;
138
170
  while (nextPageToken) {
139
171
  const nextSearch = await this.search(gaqlQuery, {
140
172
  ...requestOptions,
141
173
  page_token: nextPageToken,
142
174
  });
143
- response.push(...parser(nextSearch.response));
175
+ response.push(...nextSearch.response);
144
176
  nextPageToken = nextSearch.nextPageToken;
177
+ if (nextSearch.summaryRow) {
178
+ summaryRow = nextSearch.summaryRow;
179
+ }
180
+ }
181
+ if (summaryRow) {
182
+ response.unshift(summaryRow);
145
183
  }
146
184
  return { response, totalResultsCount };
147
185
  }
186
+ // Google's searchStream method is faster than search, but it does not support all features.
187
+ // When report() is called, we use searchStream if possible, otherwise we use paginatedSearch.
188
+ // Note that just like `paginatedSearch`, this method accumulates results in memory. Use
189
+ // `reportStream` for a more memory-efficient alternative (at the cost of more CPU usage).
190
+ async useStreamToImitateRegularSearch(gaqlQuery, requestOptions) {
191
+ const accessToken = await this.getAccessToken();
192
+ try {
193
+ const args = this.prepareGoogleAdsServicePostRequestArgs("searchStream", accessToken, {
194
+ responseType: "stream",
195
+ data: {
196
+ query: gaqlQuery,
197
+ ...requestOptions,
198
+ },
199
+ });
200
+ const response = await (0, axios_1.default)(args);
201
+ const stream = response.data;
202
+ const buffers = [];
203
+ let rowCount = -ROWS_PER_STREAMED_CHUNK;
204
+ for await (const data of stream) {
205
+ if (this.clientOptions.max_reporting_rows &&
206
+ !this.gaqlQueryStringIncludesLimit(gaqlQuery)) {
207
+ // This is a quick-and-dirty way to count rows, but it's good enough for our purposes.
208
+ // We want to avoid using a proper JSON streamer here for performance reasons.
209
+ if (data.toString("utf-8").includes(`results":`)) {
210
+ rowCount += ROWS_PER_STREAMED_CHUNK;
211
+ }
212
+ if (rowCount > this.clientOptions.max_reporting_rows) {
213
+ throw this.generateTooManyRowsError();
214
+ }
215
+ }
216
+ buffers.push(data);
217
+ }
218
+ const asString = Buffer.concat(buffers).toString("utf-8");
219
+ const accumulator = [];
220
+ let foundSummaryRow;
221
+ for (const { results, summaryRow } of JSON.parse(asString)) {
222
+ if (summaryRow) {
223
+ foundSummaryRow = this.decamelizeKeysIfNeeded(summaryRow);
224
+ }
225
+ accumulator.push(...(results ?? []).map((row) => {
226
+ return this.decamelizeKeysIfNeeded(row);
227
+ }));
228
+ if (foundSummaryRow) {
229
+ accumulator.unshift(foundSummaryRow);
230
+ }
231
+ }
232
+ return { response: accumulator };
233
+ }
234
+ catch (e) {
235
+ await this.handleStreamError(e);
236
+ throw e; // The line above should always throw.
237
+ }
238
+ }
148
239
  async querier(gaqlQuery, requestOptions = {}, reportOptions, useHooks = true) {
149
240
  const baseHookArguments = {
150
241
  credentials: this.credentials,
@@ -171,14 +262,7 @@ class Customer extends serviceFactory_1.default {
171
262
  }
172
263
  }
173
264
  try {
174
- const parsingWapper = (rows) => {
175
- return this.clientOptions.disable_parsing
176
- ? rows
177
- : reportOptions
178
- ? (0, parser_1.parse)({ results: rows, reportOptions })
179
- : (0, parser_1.parse)({ results: rows, gaqlString: gaqlQuery });
180
- };
181
- const { response, totalResultsCount } = await this.paginatedSearch(gaqlQuery, requestOptions, parsingWapper);
265
+ const { response, totalResultsCount } = await this.paginatedSearch(gaqlQuery, requestOptions);
182
266
  if (this.hooks.onQueryEnd && useHooks) {
183
267
  const queryResolution = { resolved: false };
184
268
  await this.hooks.onQueryEnd({
@@ -230,58 +314,81 @@ class Customer extends serviceFactory_1.default {
230
314
  return;
231
315
  }
232
316
  }
233
- const { service, request } = this.buildSearchStreamRequestAndService(gaqlQuery, requestOptions);
234
- const stream = service.searchStream(request, {
235
- otherArgs: { headers: this.callHeaders },
236
- });
237
- let streamFinished = false;
238
- const accumulator = [];
239
- let nextChunk = (0, utils_1.createNextChunkArrivedPromise)();
240
- stream.on("data", (chunk) => {
241
- const results = chunk.summary_row ? [chunk.summary_row] : chunk.results;
242
- const parsedResponse = this.clientOptions.disable_parsing
243
- ? results
244
- : reportOptions
245
- ? (0, parser_1.parse)({ results, reportOptions })
246
- : (0, parser_1.parse)({ results, gaqlString: gaqlQuery });
247
- accumulator.push(...parsedResponse);
248
- nextChunk.resolve();
249
- nextChunk = (0, utils_1.createNextChunkArrivedPromise)();
250
- });
251
- stream.on("error", (searchError) => {
252
- nextChunk.reject(searchError);
253
- });
254
- stream.on("end", () => {
255
- streamFinished = true;
256
- nextChunk.resolve();
257
- });
258
317
  try {
259
- while (!streamFinished || accumulator.length) {
260
- if (accumulator.length > 0) {
261
- const item = accumulator.shift();
262
- if (item === undefined) {
263
- throw new Error("UNDEFINED_STREAM_ERROR");
264
- }
265
- yield item;
318
+ const accessToken = await this.getAccessToken();
319
+ const args = this.prepareGoogleAdsServicePostRequestArgs("searchStream", accessToken, {
320
+ responseType: "stream",
321
+ data: {
322
+ query: gaqlQuery,
323
+ ...requestOptions,
324
+ },
325
+ });
326
+ const response = await (0, axios_1.default)(args);
327
+ const stream = response.data;
328
+ // The options below help to make the stream less CPU intensive.
329
+ const parser = new stream_json_1.Parser({
330
+ streamValues: false,
331
+ streamKeys: false,
332
+ packValues: true,
333
+ packKeys: true,
334
+ });
335
+ const pipeline = (0, stream_chain_1.chain)([stream, parser, (0, StreamArray_1.streamArray)()]);
336
+ let count = 0;
337
+ for await (const data of pipeline) {
338
+ const results = data.value.results ??
339
+ (data.value.summaryRow ? [data.value.summaryRow] : undefined) ??
340
+ [];
341
+ count += results.length;
342
+ if (this.clientOptions.max_reporting_rows &&
343
+ count > this.clientOptions.max_reporting_rows &&
344
+ !this.gaqlQueryStringIncludesLimit(gaqlQuery)) {
345
+ throw this.generateTooManyRowsError();
266
346
  }
267
- else {
268
- await nextChunk.newPromise;
347
+ for (const row of results) {
348
+ const parsed = this.decamelizeKeysIfNeeded(row);
349
+ yield parsed;
269
350
  }
270
351
  }
352
+ return;
271
353
  }
272
- catch (searchError) {
273
- const googleAdsError = this.getGoogleAdsError(searchError);
274
- if (this.hooks.onStreamError) {
275
- await this.hooks.onStreamError({
276
- ...baseHookArguments,
277
- error: googleAdsError,
278
- });
354
+ catch (e) {
355
+ try {
356
+ await this.handleStreamError(e);
357
+ }
358
+ catch (_e) {
359
+ if (this.hooks.onStreamError) {
360
+ await this.hooks.onStreamError({
361
+ ...baseHookArguments,
362
+ error: _e,
363
+ });
364
+ }
365
+ throw _e;
279
366
  }
280
- throw googleAdsError;
281
367
  }
282
- finally {
283
- stream.destroy();
368
+ }
369
+ async handleStreamError(e) {
370
+ if (!e?.response?.data) {
371
+ throw e;
284
372
  }
373
+ // The error is a stream, so some effort is required to parse it.
374
+ const stream = e.response.data;
375
+ const pipeline = (0, stream_chain_1.chain)([stream, (0, stream_json_1.parser)(), (0, StreamArray_1.streamArray)()]);
376
+ const defaultErrorMessage = "Unknown GoogleAdsFailure";
377
+ let googleAdsFailure = new Error(defaultErrorMessage);
378
+ // Only throw the first error.
379
+ pipeline.once("data", (data) => {
380
+ if (data?.value?.error?.details?.[0]) {
381
+ googleAdsFailure = new protos_1.errors.GoogleAdsFailure(this.decamelizeKeysIfNeeded(data.value.error.details[0]));
382
+ }
383
+ else {
384
+ googleAdsFailure = new Error(data?.value?.error?.message ?? defaultErrorMessage, { cause: data?.value?.error ?? data?.value });
385
+ }
386
+ });
387
+ // Must always reject.
388
+ await new Promise((_, reject) => {
389
+ pipeline.on("end", () => reject(googleAdsFailure));
390
+ pipeline.on("error", (err) => reject(err));
391
+ });
285
392
  }
286
393
  /**
287
394
  * @description Creates, updates, or removes resources. This method supports atomic transactions
@@ -361,5 +468,28 @@ class Customer extends serviceFactory_1.default {
361
468
  },
362
469
  };
363
470
  }
471
+ prepareGoogleAdsServicePostRequestArgs(functionName, accessToken, extra) {
472
+ return {
473
+ method: "POST",
474
+ url: `https://googleads.googleapis.com/${version_1.googleAdsVersion}/customers/${this.customerOptions.customer_id}/googleAds:${functionName}`,
475
+ headers: {
476
+ Authorization: `Bearer ${accessToken}`,
477
+ ...this.callHeaders,
478
+ },
479
+ ...extra,
480
+ };
481
+ }
482
+ decamelizeKeysIfNeeded(input) {
483
+ if (this.clientOptions.disable_parsing) {
484
+ return input;
485
+ }
486
+ return (0, parserRest_1.decamelizeKeys)(input);
487
+ }
488
+ gaqlQueryStringIncludesLimit(gaqlQuery) {
489
+ return gaqlQuery.toLowerCase().includes("limit ");
490
+ }
491
+ generateTooManyRowsError() {
492
+ return new Error(`Exceeded the maximum number of rows set by "max_reporting_rows" (${this.clientOptions.max_reporting_rows}).`);
493
+ }
364
494
  }
365
495
  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
+ };