UNPKG

google-ads-api

Version:

Google Ads API Client Library for Node.js

494 lines (493 loc) 20.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Customer = void 0; const axios_1 = __importDefault(require("axios")); const stream_chain_1 = require("stream-chain"); const stream_json_1 = require("stream-json"); const StreamArray_1 = require("stream-json/streamers/StreamArray"); const parserRest_1 = require("./parserRest"); const protos_1 = require("./protos"); const serviceFactory_1 = __importDefault(require("./protos/autogen/serviceFactory")); const query_1 = require("./query"); const version_1 = require("./version"); const ROWS_PER_STREAMED_CHUNK = 10_000; // From experience, this is what can be expected from the API. class Customer extends serviceFactory_1.default { constructor(clientOptions, customerOptions, hooks) { super(clientOptions, customerOptions, hooks ?? {}); } /** @description Single query using a raw GAQL string. @hooks onQueryStart, onQueryError, onQueryEnd */ async query(gaqlQuery, requestOptions = {}) { const { response } = await this.querier(gaqlQuery, requestOptions); return response; } /** @description Stream query using a raw GAQL string. If a generic type is provided, it must be the type of a single row. If a summary row is requested then this will be the last emitted row of the stream. @hooks onStreamStart, onStreamError @example const stream = queryStream<T>(gaqlQuery) for await (const row of stream) { ... } */ async *queryStream(gaqlQuery, requestOptions = {}) { const stream = this.streamer(gaqlQuery, requestOptions); for await (const row of stream) { yield row; } } /** @description Single query using ReportOptions. If a summary row is requested then this will be the first row of the results. @hooks onQueryStart, onQueryError, onQueryEnd */ async report(options) { const { gaqlQuery, requestOptions } = (0, query_1.buildQuery)(options); const { response } = await this.querier(gaqlQuery, requestOptions, options); return response; } /** @description Get the total row count of a report. @hooks none */ async reportCount(options) { // must get at least one row const { gaqlQuery, requestOptions } = (0, query_1.buildQuery)({ ...options, limit: 1 }); // We do not allow this field in reportOptions, however it is still a valid request option requestOptions.search_settings = { return_total_results_count: true }; const useHooks = false; // to avoid cacheing conflicts const { totalResultsCount } = await this.querier(gaqlQuery, requestOptions, options, useHooks); return totalResultsCount; } /** @description Stream query using ReportOptions. If a generic type is provided, it must be the type of a single row. If a summary row is requested then this will be the last emitted row of the stream. @hooks onStreamStart, onStreamError @example const stream = reportStream<T>(reportOptions) for await (const row of stream) { ... } */ async *reportStream(reportOptions) { const { gaqlQuery, requestOptions } = (0, query_1.buildQuery)(reportOptions); const stream = this.streamer(gaqlQuery, requestOptions, reportOptions); for await (const row of stream) { yield row; } } /** @description Retreive the raw stream using ReportOptions. @hooks onStreamStart @example const stream = reportStreamRaw(reportOptions) stream.on('data', (chunk) => { ... }) // a chunk contains up to 10,000 un-parsed rows stream.on('error', (error) => { ... }) stream.on('end', () => { ... }) */ async reportStreamRaw(reportOptions) { const { gaqlQuery, requestOptions } = (0, query_1.buildQuery)(reportOptions); const baseHookArguments = { credentials: this.credentials, query: gaqlQuery, reportOptions, }; const queryStart = { cancelled: false }; if (this.hooks.onStreamStart) { await this.hooks.onStreamStart({ ...baseHookArguments, cancel: () => { queryStart.cancelled = true; }, editOptions: (options) => { Object.entries(options).forEach(([key, val]) => { // @ts-ignore requestOptions[key] = val; }); }, }); if (queryStart.cancelled) { return; } } const { service, request } = this.buildSearchStreamRequestAndService(gaqlQuery, requestOptions); return service.searchStream(request, { otherArgs: { headers: this.callHeaders }, }); } async search(gaqlQuery, requestOptions) { const accessToken = await this.getAccessToken(); try { const rawResponse = await (0, axios_1.default)(this.prepareGoogleAdsServicePostRequestArgs("search", accessToken, { data: { query: gaqlQuery, ...requestOptions, }, })); const searchResponse = rawResponse.data; const results = searchResponse.results ?? []; const response = results.map((row) => this.decamelizeKeysIfNeeded(row)); const summaryRow = this.decamelizeKeysIfNeeded(searchResponse.summaryRow); const nextPageToken = searchResponse.nextPageToken; const totalResultsCount = searchResponse.totalResultsCount ? +searchResponse.totalResultsCount : undefined; return { response, nextPageToken, totalResultsCount, summaryRow }; } catch (e) { if (e.response?.data.error.details[0]) { throw new protos_1.errors.GoogleAdsFailure(this.decamelizeKeysIfNeeded(e.response.data.error.details[0])); } throw e; } } async paginatedSearch(gaqlQuery, requestOptions) { /* When possible, use the searchStream method to avoid the overhead of pagination. */ if (requestOptions.page_size === undefined && requestOptions.search_settings === undefined // If search_settings is set, we can't use searchStream. ) { // If no pagination or summary options are set, we can use the non-paginated search method. const { response } = await this.useStreamToImitateRegularSearch(gaqlQuery, requestOptions); return { response }; } const response = []; let nextPageToken = undefined; const initialSearch = await this.search(gaqlQuery, requestOptions); let totalResultsCount = initialSearch.totalResultsCount; // Sometimes (when no results?) the totalResultsCount field is not included in the response. // In this case, we set it to 0. if (requestOptions.search_settings?.return_total_results_count && initialSearch.totalResultsCount === undefined) { totalResultsCount = 0; } let summaryRow = initialSearch.summaryRow; response.push(...initialSearch.response); nextPageToken = initialSearch.nextPageToken; while (nextPageToken) { const nextSearch = await this.search(gaqlQuery, { ...requestOptions, page_token: nextPageToken, }); response.push(...nextSearch.response); nextPageToken = nextSearch.nextPageToken; if (nextSearch.summaryRow) { summaryRow = nextSearch.summaryRow; } } if (summaryRow) { response.unshift(summaryRow); } return { response, totalResultsCount }; } // Google's searchStream method is faster than search, but it does not support all features. // When report() is called, we use searchStream if possible, otherwise we use paginatedSearch. // Note that just like `paginatedSearch`, this method accumulates results in memory. Use // `reportStream` for a more memory-efficient alternative (at the cost of more CPU usage). async useStreamToImitateRegularSearch(gaqlQuery, requestOptions) { const accessToken = await this.getAccessToken(); try { const args = this.prepareGoogleAdsServicePostRequestArgs("searchStream", accessToken, { responseType: "stream", data: { query: gaqlQuery, ...requestOptions, }, }); const response = await (0, axios_1.default)(args); const stream = response.data; const buffers = []; let rowCount = -ROWS_PER_STREAMED_CHUNK; for await (const data of stream) { if (this.clientOptions.max_reporting_rows && !this.gaqlQueryStringIncludesLimit(gaqlQuery)) { // This is a quick-and-dirty way to count rows, but it's good enough for our purposes. // We want to avoid using a proper JSON streamer here for performance reasons. if (data.toString("utf-8").includes(`results":`)) { rowCount += ROWS_PER_STREAMED_CHUNK; } if (rowCount > this.clientOptions.max_reporting_rows) { throw this.generateTooManyRowsError(); } } buffers.push(data); } const asString = Buffer.concat(buffers).toString("utf-8"); const accumulator = []; let foundSummaryRow; for (const { results, summaryRow } of JSON.parse(asString)) { if (summaryRow) { foundSummaryRow = this.decamelizeKeysIfNeeded(summaryRow); } accumulator.push(...(results ?? []).map((row) => { return this.decamelizeKeysIfNeeded(row); })); if (foundSummaryRow) { accumulator.unshift(foundSummaryRow); } } return { response: accumulator }; } catch (e) { await this.handleStreamError(e); throw e; // The line above should always throw. } } async querier(gaqlQuery, requestOptions = {}, reportOptions, useHooks = true) { const baseHookArguments = { credentials: this.credentials, query: gaqlQuery, reportOptions, }; if (this.hooks.onQueryStart && useHooks) { const queryCancellation = { cancelled: false }; await this.hooks.onQueryStart({ ...baseHookArguments, cancel: (res) => { queryCancellation.cancelled = true; queryCancellation.res = res; }, editOptions: (options) => { Object.entries(options).forEach(([key, val]) => { // @ts-ignore requestOptions[key] = val; }); }, }); if (queryCancellation.cancelled) { return { response: queryCancellation.res }; } } try { const { response, totalResultsCount } = await this.paginatedSearch(gaqlQuery, requestOptions); if (this.hooks.onQueryEnd && useHooks) { const queryResolution = { resolved: false }; await this.hooks.onQueryEnd({ ...baseHookArguments, response, resolve: (res) => { queryResolution.resolved = true; queryResolution.res = res; }, }); if (queryResolution.resolved) { return { response: queryResolution.res, totalResultsCount }; } } return { response: response, totalResultsCount }; } catch (searchError) { const googleAdsError = this.getGoogleAdsError(searchError); if (this.hooks.onQueryError && useHooks) { await this.hooks.onQueryError({ ...baseHookArguments, error: googleAdsError, }); } throw googleAdsError; } } async *streamer(gaqlQuery, requestOptions = {}, reportOptions) { const baseHookArguments = { credentials: this.credentials, query: gaqlQuery, reportOptions, }; if (this.hooks.onStreamStart) { const queryStart = { cancelled: false }; await this.hooks.onStreamStart({ ...baseHookArguments, cancel: () => { queryStart.cancelled = true; }, editOptions: (options) => { Object.entries(options).forEach(([key, val]) => { // @ts-expect-error requestOptions[key] = val; }); }, }); if (queryStart.cancelled) { return; } } try { const accessToken = await this.getAccessToken(); const args = this.prepareGoogleAdsServicePostRequestArgs("searchStream", accessToken, { responseType: "stream", data: { query: gaqlQuery, ...requestOptions, }, }); const response = await (0, axios_1.default)(args); const stream = response.data; // The options below help to make the stream less CPU intensive. const parser = new stream_json_1.Parser({ streamValues: false, streamKeys: false, packValues: true, packKeys: true, }); const pipeline = (0, stream_chain_1.chain)([stream, parser, (0, StreamArray_1.streamArray)()]); let count = 0; for await (const data of pipeline) { const results = data.value.results ?? [data.value.summaryRow]; count += results.length; if (this.clientOptions.max_reporting_rows && count > this.clientOptions.max_reporting_rows && !this.gaqlQueryStringIncludesLimit(gaqlQuery)) { throw this.generateTooManyRowsError(); } for (const row of results) { const parsed = this.decamelizeKeysIfNeeded(row); yield parsed; } } return; } catch (e) { try { await this.handleStreamError(e); } catch (_e) { if (this.hooks.onStreamError) { await this.hooks.onStreamError({ ...baseHookArguments, error: _e, }); } throw _e; } } } async handleStreamError(e) { if (!e?.response?.data) { throw e; } // The error is a stream, so some effort is required to parse it. const stream = e.response.data; const pipeline = (0, stream_chain_1.chain)([stream, (0, stream_json_1.parser)(), (0, StreamArray_1.streamArray)()]); const defaultErrorMessage = "Unknown GoogleAdsFailure"; let googleAdsFailure = new Error(defaultErrorMessage); // Only throw the first error. pipeline.once("data", (data) => { if (data?.value?.error?.details?.[0]) { googleAdsFailure = new protos_1.errors.GoogleAdsFailure(this.decamelizeKeysIfNeeded(data.value.error.details[0])); } else { googleAdsFailure = new Error(data?.value?.error?.message ?? defaultErrorMessage, { cause: data?.value?.error ?? data?.value }); } }); // Must always reject. await new Promise((_, reject) => { pipeline.on("end", () => reject(googleAdsFailure)); pipeline.on("error", (err) => reject(err)); }); } /** * @description Creates, updates, or removes resources. This method supports atomic transactions * with multiple types of resources. For example, you can atomically create a campaign and a * campaign budget, or perform up to thousands of mutates atomically. * @hooks onMutationStart, onMutationError, onMutationEnd */ async mutateResources(mutations, mutateOptions = {}) { const baseHookArguments = { credentials: this.credentials, method: "GoogleAdsService.mutate", mutations, isServiceCall: false, }; if (this.hooks.onMutationStart) { const mutationCancellation = { cancelled: false }; await this.hooks.onMutationStart({ ...baseHookArguments, cancel: (res) => { mutationCancellation.cancelled = true; mutationCancellation.res = res; }, editOptions: (options) => { Object.entries(options).forEach(([key, val]) => { // @ts-ignore mutateOptions[key] = val; }); }, }); if (mutationCancellation.cancelled) { return mutationCancellation.res; } } const { service, request } = this.buildMutationRequestAndService(mutations, mutateOptions); try { const response = (await service.mutate(request, { otherArgs: { headers: this.callHeaders }, }))[0]; const parsedResponse = request.partial_failure ? this.decodePartialFailureError(response) : response; if (this.hooks.onMutationEnd) { const mutationResolution = { resolved: false }; await this.hooks.onMutationEnd({ ...baseHookArguments, response: parsedResponse, resolve: (res) => { mutationResolution.resolved = true; mutationResolution.res = res; }, }); if (mutationResolution.resolved) { return mutationResolution.res; } } return parsedResponse; } catch (mutateError) { const googleAdsError = this.getGoogleAdsError(mutateError); if (this.hooks.onMutationError) { await this.hooks.onMutationError({ ...baseHookArguments, error: googleAdsError, }); } throw googleAdsError; } } get googleAdsFields() { return { searchGoogleAdsFields: async (request) => { const service = await this.loadService("GoogleAdsFieldServiceClient"); return service.searchGoogleAdsFields(request, { // @ts-expect-error This method does support call headers otherArgs: { headers: this.callHeaders }, }); }, }; } prepareGoogleAdsServicePostRequestArgs(functionName, accessToken, extra) { return { method: "POST", url: `https://googleads.googleapis.com/${version_1.googleAdsVersion}/customers/${this.customerOptions.customer_id}/googleAds:${functionName}`, headers: { Authorization: `Bearer ${accessToken}`, ...this.callHeaders, }, ...extra, }; } decamelizeKeysIfNeeded(input) { if (this.clientOptions.disable_parsing) { return input; } return (0, parserRest_1.decamelizeKeys)(input); } gaqlQueryStringIncludesLimit(gaqlQuery) { return gaqlQuery.toLowerCase().includes("limit "); } generateTooManyRowsError() { return new Error(`Exceeded the maximum number of rows set by "max_reporting_rows" (${this.clientOptions.max_reporting_rows}).`); } } exports.Customer = Customer;