Skip to content

Commit dc48c4f

Browse files
✨ Source Bing Ads: custom reports (#32306)
Co-authored-by: darynaishchenko <darynaishchenko@users.noreply.github.com>
1 parent f65569e commit dc48c4f

File tree

8 files changed

+516
-11
lines changed

8 files changed

+516
-11
lines changed

airbyte-integrations/connectors/source-bing-ads/metadata.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ data:
1616
connectorSubtype: api
1717
connectorType: source
1818
definitionId: 47f25999-dd5e-4636-8c39-e7cea2453331
19-
dockerImageTag: 1.12.1
19+
dockerImageTag: 1.13.0
2020
dockerRepository: airbyte/source-bing-ads
2121
documentationUrl: https://docs.airbyte.com/integrations/sources/bing-ads
2222
githubIssueLabel: source-bing-ads

airbyte-integrations/connectors/source-bing-ads/source_bing_ads/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class Client:
3737
# https://docs.microsoft.com/en-us/advertising/guides/services-protocol?view=bingads-13#throttling
3838
# https://docs.microsoft.com/en-us/advertising/guides/operation-error-codes?view=bingads-13
3939
retry_on_codes: Iterator[str] = ["117", "207", "4204", "109", "0"]
40-
max_retries: int = 10
40+
max_retries: int = 5
4141
# A backoff factor to apply between attempts after the second try
4242
# {retry_factor} * (2 ** ({number of total retries} - 1))
4343
retry_factor: int = 15

airbyte-integrations/connectors/source-bing-ads/source_bing_ads/source.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
33
#
44
from itertools import product
5-
from typing import Any, List, Mapping, Tuple
5+
from typing import Any, List, Mapping, Optional, Tuple
66

77
from airbyte_cdk import AirbyteLogger
88
from airbyte_cdk.models import FailureType, SyncMode
@@ -41,6 +41,7 @@
4141
AgeGenderAudienceReportWeekly,
4242
AppInstallAdLabels,
4343
AppInstallAds,
44+
BingAdsReportingServiceStream,
4445
BudgetSummaryReport,
4546
CampaignImpressionPerformanceReportDaily,
4647
CampaignImpressionPerformanceReportHourly,
@@ -52,6 +53,7 @@
5253
CampaignPerformanceReportMonthly,
5354
CampaignPerformanceReportWeekly,
5455
Campaigns,
56+
CustomReport,
5557
GeographicPerformanceReportDaily,
5658
GeographicPerformanceReportHourly,
5759
GeographicPerformanceReportMonthly,
@@ -83,17 +85,49 @@ def check_connection(self, logger: AirbyteLogger, config: Mapping[str, Any]) ->
8385
try:
8486
client = Client(**config)
8587
account_ids = {str(account["Id"]) for account in Accounts(client, config).read_records(SyncMode.full_refresh)}
88+
self.validate_custom_reposts(config, client)
8689
if account_ids:
8790
return True, None
8891
else:
8992
raise AirbyteTracedException(
90-
message="Config validation error: You don't have accounts assigned to this user.",
93+
message="Config validation error: You don't have accounts assigned to this user. Please verify your developer token.",
9194
internal_message="You don't have accounts assigned to this user.",
9295
failure_type=FailureType.config_error,
9396
)
9497
except Exception as error:
9598
return False, error
9699

100+
def validate_custom_reposts(self, config: Mapping[str, Any], client: Client):
101+
custom_reports = self.get_custom_reports(config, client)
102+
for custom_report in custom_reports:
103+
is_valid, reason = custom_report.validate_report_configuration()
104+
if not is_valid:
105+
raise AirbyteTracedException(
106+
message=f"Config validation error: {custom_report.name}: {reason}",
107+
internal_message=f"{custom_report.name}: {reason}",
108+
failure_type=FailureType.config_error,
109+
)
110+
111+
def _clear_reporting_object_name(self, report_object: str) -> str:
112+
# reporting mixin adds it
113+
if report_object.endswith("Request"):
114+
return report_object.replace("Request", "")
115+
return report_object
116+
117+
def get_custom_reports(self, config: Mapping[str, Any], client: Client) -> List[Optional[Stream]]:
118+
return [
119+
type(
120+
report["name"],
121+
(CustomReport,),
122+
{
123+
"report_name": self._clear_reporting_object_name(report["reporting_object"]),
124+
"custom_report_columns": report["report_columns"],
125+
"report_aggregation": report["report_aggregation"],
126+
},
127+
)(client, config)
128+
for report in config.get("custom_reports", [])
129+
]
130+
97131
def streams(self, config: Mapping[str, Any]) -> List[Stream]:
98132
client = Client(**config)
99133
streams = [
@@ -127,4 +161,7 @@ def streams(self, config: Mapping[str, Any]) -> List[Stream]:
127161
)
128162
report_aggregation = ("Hourly", "Daily", "Weekly", "Monthly")
129163
streams.extend([eval(f"{report}{aggregation}")(client, config) for (report, aggregation) in product(reports, report_aggregation)])
164+
165+
custom_reports = self.get_custom_reports(config, client)
166+
streams.extend(custom_reports)
130167
return streams

airbyte-integrations/connectors/source-bing-ads/source_bing_ads/spec.json

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,105 @@
6363
"minimum": 0,
6464
"maximum": 90,
6565
"order": 6
66+
},
67+
"custom_reports": {
68+
"title": "Custom Reports",
69+
"description": "You can add your Custom Bing Ads report by creating one.",
70+
"order": 7,
71+
"type": "array",
72+
"items": {
73+
"title": "Custom Report Config",
74+
"type": "object",
75+
"properties": {
76+
"name": {
77+
"title": "Report Name",
78+
"description": "The name of the custom report, this name would be used as stream name",
79+
"type": "string",
80+
"examples": [
81+
"Account Performance",
82+
"AdDynamicTextPerformanceReport",
83+
"custom report"
84+
]
85+
},
86+
"reporting_object": {
87+
"title": "Reporting Data Object",
88+
"description": "The name of the the object derives from the ReportRequest object. You can find it in Bing Ads Api docs - Reporting API - Reporting Data Objects.",
89+
"type": "string",
90+
"enum": [
91+
"AccountPerformanceReportRequest",
92+
"AdDynamicTextPerformanceReportRequest",
93+
"AdExtensionByAdReportRequest",
94+
"AdExtensionByKeywordReportRequest",
95+
"AdExtensionDetailReportRequest",
96+
"AdGroupPerformanceReportRequest",
97+
"AdPerformanceReportRequest",
98+
"AgeGenderAudienceReportRequest",
99+
"AudiencePerformanceReportRequest",
100+
"CallDetailReportRequest",
101+
"CampaignPerformanceReportRequest",
102+
"ConversionPerformanceReportRequest",
103+
"DestinationUrlPerformanceReportRequest",
104+
"DSAAutoTargetPerformanceReportRequest",
105+
"DSACategoryPerformanceReportRequest",
106+
"DSASearchQueryPerformanceReportRequest",
107+
"GeographicPerformanceReportRequest",
108+
"GoalsAndFunnelsReportRequest",
109+
"HotelDimensionPerformanceReportRequest",
110+
"HotelGroupPerformanceReportRequest",
111+
"KeywordPerformanceReportRequest",
112+
"NegativeKeywordConflictReportRequest",
113+
"ProductDimensionPerformanceReportRequest",
114+
"ProductMatchCountReportRequest",
115+
"ProductNegativeKeywordConflictReportRequest",
116+
"ProductPartitionPerformanceReportRequest",
117+
"ProductPartitionUnitPerformanceReportRequest",
118+
"ProductSearchQueryPerformanceReportRequest",
119+
"ProfessionalDemographicsAudienceReportRequest",
120+
"PublisherUsagePerformanceReportRequest",
121+
"SearchCampaignChangeHistoryReportRequest",
122+
"SearchQueryPerformanceReportRequest",
123+
"ShareOfVoiceReportRequest",
124+
"UserLocationPerformanceReportRequest"
125+
]
126+
},
127+
"report_columns": {
128+
"title": "Columns",
129+
"description": "A list of available report object columns. You can find it in description of reporting object that you want to add to custom report.",
130+
"type": "array",
131+
"items": {
132+
"description": "Name of report column.",
133+
"type": "string"
134+
},
135+
"minItems": 1
136+
},
137+
"report_aggregation": {
138+
"title": "Aggregation",
139+
"description": "A list of available aggregations.",
140+
"type": "string",
141+
"items": {
142+
"title": "ValidEnums",
143+
"description": "An enumeration of aggregations.",
144+
"enum": [
145+
"Hourly",
146+
"Daily",
147+
"Weekly",
148+
"Monthly",
149+
"DayOfWeek",
150+
"HourOfDay",
151+
"WeeklyStartingMonday",
152+
"Summary"
153+
]
154+
},
155+
"default": ["Hourly"]
156+
}
157+
},
158+
"required": [
159+
"name",
160+
"reporting_object",
161+
"report_columns",
162+
"report_aggregation"
163+
]
164+
}
66165
}
67166
}
68167
},

airbyte-integrations/connectors/source-bing-ads/source_bing_ads/streams.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
33
#
44
import os
5+
import re
56
import ssl
67
import time
8+
import xml.etree.ElementTree as ET
79
from abc import ABC, abstractmethod
810
from datetime import timezone
911
from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Union
1012
from urllib.error import URLError
13+
from urllib.parse import urlparse
1114

1215
import _csv
1316
import pandas as pd
@@ -16,6 +19,7 @@
1619
from airbyte_cdk.sources.streams import IncrementalMixin, Stream
1720
from airbyte_cdk.sources.utils.transform import TransformConfig, TypeTransformer
1821
from bingads.service_client import ServiceClient
22+
from bingads.v13.internal.reporting.row_report import _RowReport
1923
from bingads.v13.internal.reporting.row_report_iterator import _RowReportRecord
2024
from bingads.v13.reporting.reporting_service_manager import ReportingServiceManager
2125
from numpy import nan
@@ -32,7 +36,7 @@
3236
PerformanceReportsMixin,
3337
ReportsMixin,
3438
)
35-
from suds import sudsobject
39+
from suds import WebFault, sudsobject
3640

3741

3842
class BingAdsBaseStream(Stream, ABC):
@@ -1260,3 +1264,95 @@ class UserLocationPerformanceReportWeekly(UserLocationPerformanceReport):
12601264

12611265
class UserLocationPerformanceReportMonthly(UserLocationPerformanceReport):
12621266
report_aggregation = "Monthly"
1267+
1268+
1269+
class CustomReport(PerformanceReportsMixin, BingAdsReportingServiceStream, ABC):
1270+
transformer: TypeTransformer = TypeTransformer(TransformConfig.DefaultSchemaNormalization)
1271+
custom_report_columns = []
1272+
report_schema_name = None
1273+
primary_key = None
1274+
1275+
@property
1276+
def cursor_field(self) -> Union[str, List[str]]:
1277+
# Summary aggregation doesn't include TimePeriod field
1278+
if self.report_aggregation != "Summary":
1279+
return "TimePeriod"
1280+
1281+
@property
1282+
def report_columns(self):
1283+
# adding common and default columns
1284+
if "AccountId" not in self.custom_report_columns:
1285+
self.custom_report_columns.append("AccountId")
1286+
if self.cursor_field and self.cursor_field not in self.custom_report_columns:
1287+
self.custom_report_columns.append(self.cursor_field)
1288+
return list(frozenset(self.custom_report_columns))
1289+
1290+
def get_json_schema(self) -> Mapping[str, Any]:
1291+
columns_schema = {col: {"type": ["null", "string"]} for col in self.report_columns}
1292+
schema: Mapping[str, Any] = {
1293+
"$schema": "https://json-schema.org/draft-07/schema#",
1294+
"type": ["null", "object"],
1295+
"additionalProperties": True,
1296+
"properties": columns_schema,
1297+
}
1298+
return schema
1299+
1300+
def validate_report_configuration(self) -> Tuple[bool, str]:
1301+
# gets /bingads/v13/proxies/production/reporting_service.xml
1302+
reporting_service_file = self.client.get_service(self.service_name)._get_service_info_dict(self.client.api_version)[
1303+
("reporting", self.client.environment)
1304+
]
1305+
tree = ET.parse(urlparse(reporting_service_file).path)
1306+
request_object = tree.find(f".//{{*}}complexType[@name='{self.report_name}Request']")
1307+
1308+
report_object_columns = self._get_object_columns(request_object, tree)
1309+
is_custom_cols_in_report_object_cols = all(x in report_object_columns for x in self.custom_report_columns)
1310+
1311+
if not is_custom_cols_in_report_object_cols:
1312+
return False, (
1313+
f"Reporting Columns are invalid. Columns that you provided don't belong to Reporting Data Object Columns:"
1314+
f" {self.custom_report_columns}. Please ensure it is correct in Bing Ads Docs."
1315+
)
1316+
1317+
return True, ""
1318+
1319+
def _clear_namespace(self, type: str) -> str:
1320+
return re.sub(r"^[a-z]+:", "", type)
1321+
1322+
def _get_object_columns(self, request_el: ET.Element, tree: ET.ElementTree) -> List[str]:
1323+
column_el = request_el.find(".//{*}element[@name='Columns']")
1324+
array_of_columns_name = self._clear_namespace(column_el.get("type"))
1325+
1326+
array_of_columns_elements = tree.find(f".//{{*}}complexType[@name='{array_of_columns_name}']")
1327+
inner_array_of_columns_elements = array_of_columns_elements.find(".//{*}element")
1328+
column_el_name = self._clear_namespace(inner_array_of_columns_elements.get("type"))
1329+
1330+
column_el = tree.find(f".//{{*}}simpleType[@name='{column_el_name}']")
1331+
column_enum_items = column_el.findall(".//{*}enumeration")
1332+
column_enum_items_values = [el.get("value") for el in column_enum_items]
1333+
return column_enum_items_values
1334+
1335+
def get_report_record_timestamp(self, datestring: str) -> int:
1336+
"""
1337+
Parse report date field based on aggregation type
1338+
"""
1339+
if not self.report_aggregation:
1340+
date = pendulum.from_format(datestring, "M/D/YYYY")
1341+
else:
1342+
if self.report_aggregation in ["DayOfWeek", "HourOfDay"]:
1343+
return int(datestring)
1344+
if self.report_aggregation == "Hourly":
1345+
date = pendulum.from_format(datestring, "YYYY-MM-DD|H")
1346+
else:
1347+
date = pendulum.parse(datestring)
1348+
1349+
return date.int_timestamp
1350+
1351+
def send_request(self, params: Mapping[str, Any], customer_id: str, account_id: str) -> _RowReport:
1352+
try:
1353+
return super().send_request(params, customer_id, account_id)
1354+
except WebFault as e:
1355+
self.logger.error(
1356+
f"Could not sync custom report {self.name}: Please validate your column and aggregation configuration. "
1357+
f"Error form server: [{e.fault.faultstring}]"
1358+
)

airbyte-integrations/connectors/source-bing-ads/unit_tests/test_client.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import source_bing_ads.client
1313
from airbyte_cdk.utils import AirbyteTracedException
1414
from bingads.authorization import AuthorizationData, OAuthTokens
15+
from bingads.v13.bulk import BulkServiceManager
1516
from bingads.v13.reporting.exceptions import ReportingDownloadException
1617
from suds import sudsobject
1718

@@ -176,3 +177,15 @@ def test_bulk_service_manager(patched_request_tokens):
176177
client = source_bing_ads.client.Client("tenant_id", "2020-01-01", client_id="client_id", refresh_token="refresh_token")
177178
service = client._bulk_service_manager()
178179
assert (service._poll_interval_in_milliseconds, service._environment) == (5000, client.environment)
180+
181+
182+
def test_get_bulk_entity(requests_mock):
183+
requests_mock.post(
184+
"https://login.microsoftonline.com/tenant_id/oauth2/v2.0/token",
185+
status_code=200,
186+
json={"access_token": "test", "expires_in": "9000", "refresh_token": "test"},
187+
)
188+
client = source_bing_ads.client.Client("tenant_id", "2020-01-01", client_id="client_id", refresh_token="refresh_token")
189+
with patch.object(BulkServiceManager, "download_file", return_value="file.csv"):
190+
bulk_entity = client.get_bulk_entity(data_scope=["EntityData"], download_entities=["AppInstallAds"])
191+
assert bulk_entity == "file.csv"

0 commit comments

Comments
 (0)