Skip to content

Commit 13e0a9e

Browse files
Merge pull request #521 from Opteo/V21
V21
2 parents 861889d + 2db4741 commit 13e0a9e

16 files changed

Lines changed: 834 additions & 417 deletions

.prettierrc.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module.exports = {
2+
printWidth: 80,
3+
tabWidth: 2,
4+
useTabs: false,
5+
semi: true,
6+
singleQuote: false,
7+
trailingComma: 'es5',
8+
bracketSpacing: true,
9+
//arrowParens: 'avoid',
10+
rangeStart: 0,
11+
rangeEnd: Infinity,
12+
};

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# Changelog
22

3+
### 21.0.0-
4+
5+
### Version Upgrade
6+
7+
- Upgraded google-ads-api version to v21. Refer to Google ads release notes [here](https://developers.google.com/google-ads/api/docs/release-notes) for changes.
8+
- Upgraded google-ads-node dependency to v18.0.0
9+
10+
### Bug Fixes
11+
12+
- Fixed parsing of FieldMask fields (like `changed_fields`) in REST API responses ([#519](https://github.com/Opteo/google-ads-api/issues/519))
13+
- REST API returns FieldMask fields as comma-separated strings (e.g., `"field1,field2"`) which were not being properly parsed
14+
- Now correctly converts them to objects with a `paths` array format: `{ paths: ["field1", "field2"] }`
15+
- Handles case conversion from camelCase to snake_case to maintain consistency with the rest of the library
16+
- Properly processes nested paths (e.g., `"ipBlock.ipAddress"``"ip_block.ip_address"`)
17+
- The `changed_fields` field now works correctly and follows the same naming conventions as the rest of the library
18+
19+
### Library Changes
20+
21+
- Updated enum definitions to support Google Ads API v21 changes
22+
- Enhanced parser test coverage with additional test cases for change events and FieldMask fields
23+
- Added `skipLibCheck` to TypeScript configuration for faster compilation
24+
- Added prettier configuration for consistent code formatting
25+
326
### 20.0.1
427

528
### Version Upgrade

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "google-ads-api",
3-
"version": "20.0.1",
3+
"version": "21.0.0",
44
"description": "Google Ads API Client Library for Node.js",
55
"repository": "https://github.com/Opteo/google-ads-api",
66
"main": "build/src/index.js",
@@ -21,17 +21,17 @@
2121
"@isaacs/ttlcache": "^1.2.2",
2222
"axios": "^1.6.7",
2323
"circ-json": "^1.0.4",
24-
"google-ads-node": "17.0.1",
24+
"google-ads-node": "18.0.0",
2525
"google-auth-library": "^9.15.1",
26-
"google-gax": "^5.1.1-rc.1",
26+
"google-gax": "^5.0.1",
2727
"long": "^4.0.0",
2828
"map-obj": "^4.0.0",
2929
"stream-json": "^1.8.0"
3030
},
3131
"devDependencies": {
3232
"@types/jest": "^29.0.1",
33-
"@types/long": "^4.0.0",
3433
"@types/lodash": "^4.14.202",
34+
"@types/long": "^4.0.0",
3535
"@types/node": "^22.5.4",
3636
"@types/pluralize": "^0.0.29",
3737
"@types/stream-json": "^1.7.7",
@@ -43,6 +43,7 @@
4343
"jest": "^29.7.0",
4444
"lodash": "^4.17.21",
4545
"pluralize": "^8.0.0",
46+
"prettier": "^3.6.2",
4647
"protobufjs": "^7.2.6",
4748
"ts-jest": "^29.1.2",
4849
"tsx": "^4.19.3",

scripts/resourceName.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ export interface Text {
3737
function: string; // "export function accountBudget(customerId: string | number, accountBudgetId: string | number): AccountBudgetResourceName { return `customers/${customerId}/accountBudgets/${accountBudgetId}` as const }"
3838
}
3939

40-
export function generateTextParts(
41-
pathTemplate: PathTemplate
42-
): { parts: Parts; comments: Comments } {
40+
export function generateTextParts(pathTemplate: PathTemplate): {
41+
parts: Parts;
42+
comments: Comments;
43+
} {
4344
const resource = pathTemplate.path.replace(/PathTemplate/g, "");
4445

4546
const parts: Parts = {
@@ -101,13 +102,27 @@ function buildResourceNameBuilder(stream: fs.WriteStream, text: Text): void {
101102
export async function compileResourceNameFunctions(): Promise<void> {
102103
const service = new CampaignServiceClient();
103104

104-
// @ts-expect-error
105-
const pathTemplatesRaw: { [path: string]: Omit<PathTemplate, "path"> } =
106-
service.pathTemplates;
105+
const pathTemplatesRaw = service.pathTemplates;
107106

108107
const pathTemplates = Object.entries(pathTemplatesRaw).map(
109-
([path, template]: [string, Omit<PathTemplate, "path">]) => {
110-
return { path, ...template };
108+
([path, template]) => {
109+
// Extract bindings from segments (public property)
110+
const bindings: { [key: string]: string } = {};
111+
template.segments.forEach((segment) => {
112+
const match = segment.match(/\{([^=}]+)(?:=([^}]+))?\}/);
113+
if (match) {
114+
bindings[match[1]] = match[2] || "*";
115+
}
116+
});
117+
118+
// Use inspect() to get the template string
119+
const data = template.inspect();
120+
121+
return {
122+
path,
123+
bindings,
124+
data,
125+
};
111126
}
112127
);
113128

src/customer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ export class Customer extends ServiceFactory {
100100
const { gaqlQuery, requestOptions } = buildQuery({ ...options, limit: 1 });
101101

102102
// We do not allow this field in reportOptions, however it is still a valid request option
103-
requestOptions.search_settings = { return_total_results_count: true };
103+
requestOptions.search_settings = {
104+
return_total_results_count: true,
105+
return_summary_row: false,
106+
};
104107

105108
const useHooks = false; // to avoid cacheing conflicts
106109
const { totalResultsCount } = await this.querier(
@@ -369,7 +372,7 @@ export class Customer extends ServiceFactory {
369372

370373
private async querier<T = services.IGoogleAdsRow[]>(
371374
gaqlQuery: string,
372-
requestOptions: RequestOptions = {},
375+
requestOptions: RequestOptionsWithTotalResults = {},
373376
reportOptions?: Readonly<ReportOptions>,
374377
useHooks = true
375378
): Promise<{ response: T; totalResultsCount?: number }> {

src/hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type ErrorHookArgs = {
3939
};
4040

4141
type EndHookArgs<
42-
T = services.IGoogleAdsRow[] | services.MutateGoogleAdsResponse
42+
T = services.IGoogleAdsRow[] | services.MutateGoogleAdsResponse,
4343
> = {
4444
response?: T;
4545
resolve: (args: any) => void;

src/parser.spec.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,192 @@ describe("parseRows", () => {
234234
},
235235
]);
236236
});
237+
238+
it("handles FieldMask fields like changed_fields", () => {
239+
const fields = [
240+
"change_event.resource_name",
241+
"change_event.changed_fields",
242+
];
243+
const rows: services.IGoogleAdsRow[] = [
244+
{
245+
change_event: {
246+
resource_name:
247+
"customers/4517895542/changeEvents/1750751693628134~0~0",
248+
change_date_time: "2025-06-24 08:54:53.628134",
249+
change_resource_name: "customers/4517895542/campaigns/21930728598",
250+
changed_fields: {
251+
paths: ["campaign.target_roas.target_roas"],
252+
},
253+
old_resource: { campaign: { target_roas: { target_roas: 3 } } },
254+
new_resource: { campaign: { target_roas: { target_roas: 3.25 } } },
255+
},
256+
},
257+
];
258+
const result = parseRows(rows, fields);
259+
expect(result).toHaveLength(1);
260+
expect(result[0].change_event?.resource_name).toBe(
261+
"customers/4517895542/changeEvents/1750751693628134~0~0"
262+
);
263+
expect(result[0].change_event?.changed_fields).toEqual({
264+
paths: ["campaign.target_roas.target_roas"],
265+
});
266+
});
267+
268+
it("handles old_resource and new_resource fields in change events", () => {
269+
const fields = [
270+
"change_event.resource_name",
271+
"change_event.old_resource",
272+
"change_event.new_resource",
273+
];
274+
const rows: services.IGoogleAdsRow[] = [
275+
{
276+
change_event: {
277+
resource_name:
278+
"customers/4517895542/changeEvents/1750751693628134~0~0",
279+
change_date_time: "2025-06-24 08:54:53.628134",
280+
change_resource_name: "customers/4517895542/campaigns/21930728598",
281+
changed_fields: {
282+
paths: ["campaign.target_roas.target_roas"],
283+
},
284+
old_resource: { campaign: { target_roas: { target_roas: 3 } } },
285+
new_resource: { campaign: { target_roas: { target_roas: 3.25 } } },
286+
},
287+
},
288+
];
289+
const result = parseRows(rows, fields);
290+
expect(result).toHaveLength(1);
291+
expect(result[0].change_event?.resource_name).toBe(
292+
"customers/4517895542/changeEvents/1750751693628134~0~0"
293+
);
294+
expect(
295+
result[0].change_event?.old_resource?.campaign?.target_roas?.target_roas
296+
).toBe(3);
297+
expect(
298+
result[0].change_event?.new_resource?.campaign?.target_roas?.target_roas
299+
).toBe(3.25);
300+
});
301+
302+
it("handles complex nested structures in old_resource and new_resource", () => {
303+
const fields = [
304+
"change_event.resource_name",
305+
"change_event.old_resource",
306+
"change_event.new_resource",
307+
];
308+
const rows: services.IGoogleAdsRow[] = [
309+
{
310+
change_event: {
311+
resource_name:
312+
"customers/4517895542/changeEvents/1750751666945610~0~0",
313+
change_date_time: "2025-06-24 08:54:26.94561",
314+
change_resource_name: "customers/4517895542/campaigns/17049405489",
315+
changed_fields: {
316+
paths: ["maximize_conversion_value.target_roas"],
317+
},
318+
old_resource: {
319+
campaign: {
320+
maximize_conversion_value: {
321+
target_roas: 8,
322+
},
323+
},
324+
},
325+
new_resource: {
326+
campaign: {
327+
maximize_conversion_value: {
328+
target_roas: 8.5,
329+
},
330+
},
331+
},
332+
},
333+
},
334+
];
335+
const result = parseRows(rows, fields);
336+
expect(result).toHaveLength(1);
337+
expect(
338+
result[0].change_event?.old_resource?.campaign?.maximize_conversion_value
339+
?.target_roas
340+
).toBe(8);
341+
expect(
342+
result[0].change_event?.new_resource?.campaign?.maximize_conversion_value
343+
?.target_roas
344+
).toBe(8.5);
345+
});
346+
347+
it("handles campaign criterion deletion in old_resource and new_resource", () => {
348+
const fields = [
349+
"change_event.resource_name",
350+
"change_event.old_resource",
351+
"change_event.new_resource",
352+
];
353+
const rows: services.IGoogleAdsRow[] = [
354+
{
355+
change_event: {
356+
resource_name:
357+
"customers/4517895542/changeEvents/1750693690782591~0~0",
358+
change_date_time: "2025-06-23 16:48:10.782591",
359+
change_resource_name:
360+
"customers/4517895542/campaignCriteria/21890334919~23340370",
361+
changed_fields: {
362+
paths: [
363+
"campaign",
364+
"criterion_id",
365+
"keyword.match_type",
366+
"keyword.text",
367+
"negative",
368+
"resource_name",
369+
"status",
370+
],
371+
},
372+
old_resource: {
373+
campaign_criterion: {
374+
resource_name:
375+
"customers/4517895542/campaignCriteria/21890334919~23340370",
376+
keyword: {
377+
match_type: "BROAD",
378+
text: "crackle",
379+
},
380+
status: "ENABLED",
381+
campaign: "customers/4517895542/campaigns/21890334919",
382+
criterion_id: 23340370,
383+
negative: true,
384+
},
385+
},
386+
new_resource: {
387+
campaign_criterion: {},
388+
},
389+
},
390+
},
391+
];
392+
const result = parseRows(rows, fields);
393+
expect(result).toHaveLength(1);
394+
expect(
395+
result[0].change_event?.old_resource?.campaign_criterion?.resource_name
396+
).toBe("customers/4517895542/campaignCriteria/21890334919~23340370");
397+
expect(
398+
result[0].change_event?.old_resource?.campaign_criterion?.keyword
399+
?.match_type
400+
).toBe(4);
401+
expect(
402+
result[0].change_event?.old_resource?.campaign_criterion?.keyword?.text
403+
).toBe("crackle");
404+
expect(
405+
result[0].change_event?.old_resource?.campaign_criterion?.status
406+
).toBe(2);
407+
expect(
408+
result[0].change_event?.old_resource?.campaign_criterion?.campaign
409+
).toBe("customers/4517895542/campaigns/21890334919");
410+
expect(
411+
(
412+
result[0].change_event?.old_resource?.campaign_criterion
413+
?.criterion_id as any
414+
)?.low
415+
).toBe(23340370);
416+
expect(
417+
result[0].change_event?.old_resource?.campaign_criterion?.negative
418+
).toBe(true);
419+
expect(
420+
result[0].change_event?.new_resource?.campaign_criterion
421+
).toBeDefined();
422+
});
237423
});
238424

239425
describe("getGAQLFields", () => {

src/parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export function parseRows(
106106
// @ts-expect-error These are the best we can do for these types
107107
const [parent, ...children]: [
108108
fields.Resource,
109-
...(keyof services.IGoogleAdsRow)[]
109+
...(keyof services.IGoogleAdsRow)[],
110110
] = fieldsPreSplit[split];
111111

112112
// Ignore null fields (unspecified resource names)

src/parserRest.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,24 @@ const cachedValueParser = (
7171
if (megaDataType === undefined && !fullPath.startsWith("@")) {
7272
console.warn(`No data type found for ${fullPath}`);
7373
} else if (typeof megaDataType === "object") {
74-
newValue = megaDataType[value];
74+
// Special handling for FieldMask types - REST API returns them as comma-separated strings
75+
if (megaDataType.paths === "STRING" && typeof value === "string") {
76+
// This is a FieldMask field, convert the comma-separated string to the expected format
77+
// Also convert each path from camelCase to snake_case
78+
newValue = {
79+
paths: value.split(",").map((p) => {
80+
// Handle nested paths like "ipBlock.ipAddress"
81+
return p
82+
.trim()
83+
.split(".")
84+
.map((segment) => toSnakeCase(segment))
85+
.join(".");
86+
}),
87+
};
88+
} else {
89+
// Normal enum handling
90+
newValue = megaDataType[value];
91+
}
7592
} else if (megaDataType === "INT64") {
7693
newValue = Number(value);
7794
} else if (megaDataType === "ENUM") {

0 commit comments

Comments
 (0)