google-ads-api
Version:
Google Ads API Client Library for Node.js
494 lines (493 loc) • 20.9 kB
JavaScript
;
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;