Skip to content
Merged

V21 #521

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = {
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: false,
trailingComma: 'es5',
bracketSpacing: true,
//arrowParens: 'avoid',
rangeStart: 0,
rangeEnd: Infinity,
};
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

### 21.0.0-

### Version Upgrade

- 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.
- Upgraded google-ads-node dependency to v18.0.0

### Bug Fixes

- Fixed parsing of FieldMask fields (like `changed_fields`) in REST API responses ([#519](https://github.com/Opteo/google-ads-api/issues/519))
- REST API returns FieldMask fields as comma-separated strings (e.g., `"field1,field2"`) which were not being properly parsed
- Now correctly converts them to objects with a `paths` array format: `{ paths: ["field1", "field2"] }`
- Handles case conversion from camelCase to snake_case to maintain consistency with the rest of the library
- Properly processes nested paths (e.g., `"ipBlock.ipAddress"` → `"ip_block.ip_address"`)
- The `changed_fields` field now works correctly and follows the same naming conventions as the rest of the library

### Library Changes

- Updated enum definitions to support Google Ads API v21 changes
- Enhanced parser test coverage with additional test cases for change events and FieldMask fields
- Added `skipLibCheck` to TypeScript configuration for faster compilation
- Added prettier configuration for consistent code formatting

### 20.0.1

### Version Upgrade
Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "google-ads-api",
"version": "20.0.1",
"version": "21.0.0",
"description": "Google Ads API Client Library for Node.js",
"repository": "https://github.com/Opteo/google-ads-api",
"main": "build/src/index.js",
Expand All @@ -21,17 +21,17 @@
"@isaacs/ttlcache": "^1.2.2",
"axios": "^1.6.7",
"circ-json": "^1.0.4",
"google-ads-node": "17.0.1",
"google-ads-node": "18.0.0",
"google-auth-library": "^9.15.1",
"google-gax": "^5.1.1-rc.1",
"google-gax": "^5.0.1",
"long": "^4.0.0",
"map-obj": "^4.0.0",
"stream-json": "^1.8.0"
},
"devDependencies": {
"@types/jest": "^29.0.1",
"@types/long": "^4.0.0",
"@types/lodash": "^4.14.202",
"@types/long": "^4.0.0",
"@types/node": "^22.5.4",
"@types/pluralize": "^0.0.29",
"@types/stream-json": "^1.7.7",
Expand All @@ -43,6 +43,7 @@
"jest": "^29.7.0",
"lodash": "^4.17.21",
"pluralize": "^8.0.0",
"prettier": "^3.6.2",
"protobufjs": "^7.2.6",
"ts-jest": "^29.1.2",
"tsx": "^4.19.3",
Expand Down
31 changes: 23 additions & 8 deletions scripts/resourceName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ export interface Text {
function: string; // "export function accountBudget(customerId: string | number, accountBudgetId: string | number): AccountBudgetResourceName { return `customers/${customerId}/accountBudgets/${accountBudgetId}` as const }"
}

export function generateTextParts(
pathTemplate: PathTemplate
): { parts: Parts; comments: Comments } {
export function generateTextParts(pathTemplate: PathTemplate): {
parts: Parts;
comments: Comments;
} {
const resource = pathTemplate.path.replace(/PathTemplate/g, "");

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

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

const pathTemplates = Object.entries(pathTemplatesRaw).map(
([path, template]: [string, Omit<PathTemplate, "path">]) => {
return { path, ...template };
([path, template]) => {
// Extract bindings from segments (public property)
const bindings: { [key: string]: string } = {};
template.segments.forEach((segment) => {
const match = segment.match(/\{([^=}]+)(?:=([^}]+))?\}/);
if (match) {
bindings[match[1]] = match[2] || "*";
}
});

// Use inspect() to get the template string
const data = template.inspect();

return {
path,
bindings,
data,
};
}
);

Expand Down
7 changes: 5 additions & 2 deletions src/customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ export class Customer extends ServiceFactory {
const { gaqlQuery, requestOptions } = 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 };
requestOptions.search_settings = {
return_total_results_count: true,
return_summary_row: false,
};

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

private async querier<T = services.IGoogleAdsRow[]>(
gaqlQuery: string,
requestOptions: RequestOptions = {},
requestOptions: RequestOptionsWithTotalResults = {},
reportOptions?: Readonly<ReportOptions>,
useHooks = true
): Promise<{ response: T; totalResultsCount?: number }> {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type ErrorHookArgs = {
};

type EndHookArgs<
T = services.IGoogleAdsRow[] | services.MutateGoogleAdsResponse
T = services.IGoogleAdsRow[] | services.MutateGoogleAdsResponse,
> = {
response?: T;
resolve: (args: any) => void;
Expand Down
186 changes: 186 additions & 0 deletions src/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,192 @@ describe("parseRows", () => {
},
]);
});

it("handles FieldMask fields like changed_fields", () => {
const fields = [
"change_event.resource_name",
"change_event.changed_fields",
];
const rows: services.IGoogleAdsRow[] = [
{
change_event: {
resource_name:
"customers/4517895542/changeEvents/1750751693628134~0~0",
change_date_time: "2025-06-24 08:54:53.628134",
change_resource_name: "customers/4517895542/campaigns/21930728598",
changed_fields: {
paths: ["campaign.target_roas.target_roas"],
},
old_resource: { campaign: { target_roas: { target_roas: 3 } } },
new_resource: { campaign: { target_roas: { target_roas: 3.25 } } },
},
},
];
const result = parseRows(rows, fields);
expect(result).toHaveLength(1);
expect(result[0].change_event?.resource_name).toBe(
"customers/4517895542/changeEvents/1750751693628134~0~0"
);
expect(result[0].change_event?.changed_fields).toEqual({
paths: ["campaign.target_roas.target_roas"],
});
});

it("handles old_resource and new_resource fields in change events", () => {
const fields = [
"change_event.resource_name",
"change_event.old_resource",
"change_event.new_resource",
];
const rows: services.IGoogleAdsRow[] = [
{
change_event: {
resource_name:
"customers/4517895542/changeEvents/1750751693628134~0~0",
change_date_time: "2025-06-24 08:54:53.628134",
change_resource_name: "customers/4517895542/campaigns/21930728598",
changed_fields: {
paths: ["campaign.target_roas.target_roas"],
},
old_resource: { campaign: { target_roas: { target_roas: 3 } } },
new_resource: { campaign: { target_roas: { target_roas: 3.25 } } },
},
},
];
const result = parseRows(rows, fields);
expect(result).toHaveLength(1);
expect(result[0].change_event?.resource_name).toBe(
"customers/4517895542/changeEvents/1750751693628134~0~0"
);
expect(
result[0].change_event?.old_resource?.campaign?.target_roas?.target_roas
).toBe(3);
expect(
result[0].change_event?.new_resource?.campaign?.target_roas?.target_roas
).toBe(3.25);
});

it("handles complex nested structures in old_resource and new_resource", () => {
const fields = [
"change_event.resource_name",
"change_event.old_resource",
"change_event.new_resource",
];
const rows: services.IGoogleAdsRow[] = [
{
change_event: {
resource_name:
"customers/4517895542/changeEvents/1750751666945610~0~0",
change_date_time: "2025-06-24 08:54:26.94561",
change_resource_name: "customers/4517895542/campaigns/17049405489",
changed_fields: {
paths: ["maximize_conversion_value.target_roas"],
},
old_resource: {
campaign: {
maximize_conversion_value: {
target_roas: 8,
},
},
},
new_resource: {
campaign: {
maximize_conversion_value: {
target_roas: 8.5,
},
},
},
},
},
];
const result = parseRows(rows, fields);
expect(result).toHaveLength(1);
expect(
result[0].change_event?.old_resource?.campaign?.maximize_conversion_value
?.target_roas
).toBe(8);
expect(
result[0].change_event?.new_resource?.campaign?.maximize_conversion_value
?.target_roas
).toBe(8.5);
});

it("handles campaign criterion deletion in old_resource and new_resource", () => {
const fields = [
"change_event.resource_name",
"change_event.old_resource",
"change_event.new_resource",
];
const rows: services.IGoogleAdsRow[] = [
{
change_event: {
resource_name:
"customers/4517895542/changeEvents/1750693690782591~0~0",
change_date_time: "2025-06-23 16:48:10.782591",
change_resource_name:
"customers/4517895542/campaignCriteria/21890334919~23340370",
changed_fields: {
paths: [
"campaign",
"criterion_id",
"keyword.match_type",
"keyword.text",
"negative",
"resource_name",
"status",
],
},
old_resource: {
campaign_criterion: {
resource_name:
"customers/4517895542/campaignCriteria/21890334919~23340370",
keyword: {
match_type: "BROAD",
text: "crackle",
},
status: "ENABLED",
campaign: "customers/4517895542/campaigns/21890334919",
criterion_id: 23340370,
negative: true,
},
},
new_resource: {
campaign_criterion: {},
},
},
},
];
const result = parseRows(rows, fields);
expect(result).toHaveLength(1);
expect(
result[0].change_event?.old_resource?.campaign_criterion?.resource_name
).toBe("customers/4517895542/campaignCriteria/21890334919~23340370");
expect(
result[0].change_event?.old_resource?.campaign_criterion?.keyword
?.match_type
).toBe(4);
expect(
result[0].change_event?.old_resource?.campaign_criterion?.keyword?.text
).toBe("crackle");
expect(
result[0].change_event?.old_resource?.campaign_criterion?.status
).toBe(2);
expect(
result[0].change_event?.old_resource?.campaign_criterion?.campaign
).toBe("customers/4517895542/campaigns/21890334919");
expect(
(
result[0].change_event?.old_resource?.campaign_criterion
?.criterion_id as any
)?.low
).toBe(23340370);
expect(
result[0].change_event?.old_resource?.campaign_criterion?.negative
).toBe(true);
expect(
result[0].change_event?.new_resource?.campaign_criterion
).toBeDefined();
});
});

describe("getGAQLFields", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function parseRows(
// @ts-expect-error These are the best we can do for these types
const [parent, ...children]: [
fields.Resource,
...(keyof services.IGoogleAdsRow)[]
...(keyof services.IGoogleAdsRow)[],
] = fieldsPreSplit[split];

// Ignore null fields (unspecified resource names)
Expand Down
19 changes: 18 additions & 1 deletion src/parserRest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,24 @@ const cachedValueParser = (
if (megaDataType === undefined && !fullPath.startsWith("@")) {
console.warn(`No data type found for ${fullPath}`);
} else if (typeof megaDataType === "object") {
newValue = megaDataType[value];
// Special handling for FieldMask types - REST API returns them as comma-separated strings
if (megaDataType.paths === "STRING" && typeof value === "string") {
// This is a FieldMask field, convert the comma-separated string to the expected format
// Also convert each path from camelCase to snake_case
newValue = {
paths: value.split(",").map((p) => {
// Handle nested paths like "ipBlock.ipAddress"
return p
.trim()
.split(".")
.map((segment) => toSnakeCase(segment))
.join(".");
}),
};
} else {
// Normal enum handling
newValue = megaDataType[value];
}
} else if (megaDataType === "INT64") {
newValue = Number(value);
} else if (megaDataType === "ENUM") {
Expand Down
Loading
Loading