This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 107b3e2621 Add developer token as authentication method to
GoogleAdsHook (#37417)
107b3e2621 is described below
commit 107b3e2621977e681080af08555bf2b8464d2df1
Author: Leon <[email protected]>
AuthorDate: Thu Feb 15 16:09:46 2024 +0100
Add developer token as authentication method to GoogleAdsHook (#37417)
* Add developer token as authentication method to GoogleAdsHook
* Refactor method name and if statement
---------
Co-authored-by: Leon Graveland <[email protected]>
---
airflow/providers/google/ads/hooks/ads.py | 93 +++++++++++++++++++++-------
tests/providers/google/ads/hooks/test_ads.py | 49 +++++++++++++--
2 files changed, 114 insertions(+), 28 deletions(-)
diff --git a/airflow/providers/google/ads/hooks/ads.py
b/airflow/providers/google/ads/hooks/ads.py
index 822efe5dfc..d86673e023 100644
--- a/airflow/providers/google/ads/hooks/ads.py
+++ b/airflow/providers/google/ads/hooks/ads.py
@@ -20,7 +20,7 @@ from __future__ import annotations
from functools import cached_property
from tempfile import NamedTemporaryFile
-from typing import IO, TYPE_CHECKING, Any
+from typing import IO, TYPE_CHECKING, Any, Literal
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
@@ -40,32 +40,59 @@ if TYPE_CHECKING:
class GoogleAdsHook(BaseHook):
"""Interact with Google Ads API.
- This hook requires two connections:
+ This hook offers two flows of authentication.
- - gcp_conn_id - provides service account details (like any other GCP
connection)
- - google_ads_conn_id - which contains information from Google Ads
config.yaml file
- in the ``extras``. Example of the ``extras``:
+ 1. OAuth Service Account Flow (requires two connections)
- .. code-block:: json
+ - gcp_conn_id - provides service account details (like any other GCP
connection)
+ - google_ads_conn_id - which contains information from Google Ads
config.yaml file
+ in the ``extras``. Example of the ``extras``:
- {
- "google_ads_client": {
- "developer_token": "{{ INSERT_TOKEN }}",
- "json_key_file_path": null,
- "impersonated_email": "{{ INSERT_IMPERSONATED_EMAIL }}"
+ .. code-block:: json
+
+ {
+ "google_ads_client": {
+ "developer_token": "{{ INSERT_TOKEN }}",
+ "json_key_file_path": null,
+ "impersonated_email": "{{ INSERT_IMPERSONATED_EMAIL }}"
+ }
}
- }
- The ``json_key_file_path`` is resolved by the hook using credentials
from gcp_conn_id.
-
https://developers.google.com/google-ads/api/docs/client-libs/python/oauth-service
+ The ``json_key_file_path`` is resolved by the hook using
credentials from gcp_conn_id.
+
https://developers.google.com/google-ads/api/docs/client-libs/python/oauth-service
+
+ .. seealso::
+ For more information on how Google Ads authentication flow works
take a look at:
+
https://developers.google.com/google-ads/api/docs/client-libs/python/oauth-service
+
+ .. seealso::
+ For more information on the Google Ads API, take a look at the API
docs:
+ https://developers.google.com/google-ads/api/docs/start
- .. seealso::
- For more information on how Google Ads authentication flow works take
a look at:
-
https://developers.google.com/google-ads/api/docs/client-libs/python/oauth-service
+ 2. Developer token from API center flow (only requires google_ads_conn_id)
- .. seealso::
- For more information on the Google Ads API, take a look at the API
docs:
- https://developers.google.com/google-ads/api/docs/start
+ - google_ads_conn_id - which contains developer token, refresh token,
client_id and client_secret
+ in the ``extras``. Example of the ``extras``:
+
+ .. code-block:: json
+
+ {
+ "google_ads_client": {
+ "developer_token": "{{ INSERT_DEVELOPER_TOKEN }}",
+ "refresh_token": "{{ INSERT_REFRESH_TOKEN }}",
+ "client_id": "{{ INSERT_CLIENT_ID }}",
+ "client_secret": "{{ INSERT_CLIENT_SECRET }}",
+ "use_proto_plus": "{{ True or False }}",
+ }
+ }
+
+ .. seealso::
+ For more information on how to obtain a developer token look at:
+
https://developers.google.com/google-ads/api/docs/get-started/dev-token
+
+ .. seealso::
+ For more information about use_proto_plus option see the Protobuf
Messages guide:
+
https://developers.google.com/google-ads/api/docs/client-libs/python/protobuf-messages
:param gcp_conn_id: The connection ID with the service account details.
:param google_ads_conn_id: The connection ID with the details of Google
Ads config.yaml file.
@@ -85,6 +112,7 @@ class GoogleAdsHook(BaseHook):
self.gcp_conn_id = gcp_conn_id
self.google_ads_conn_id = google_ads_conn_id
self.google_ads_config: dict[str, Any] = {}
+ self.authentication_method: Literal["service_account",
"developer_token"] = "service_account"
def search(
self, client_ids: list[str], query: str, page_size: int = 10000,
**kwargs
@@ -162,7 +190,10 @@ class GoogleAdsHook(BaseHook):
def _get_client(self) -> GoogleAdsClient:
with NamedTemporaryFile("w", suffix=".json") as secrets_temp:
self._get_config()
- self._update_config_with_secret(secrets_temp)
+ self._determine_authentication_method()
+ self._update_config_with_secret(
+ secrets_temp
+ ) if self.authentication_method == "service_account" else None
try:
client = GoogleAdsClient.load_from_dict(self.google_ads_config)
return client
@@ -175,7 +206,9 @@ class GoogleAdsHook(BaseHook):
"""Connect and authenticate with the Google Ads API using a service
account."""
with NamedTemporaryFile("w", suffix=".json") as secrets_temp:
self._get_config()
- self._update_config_with_secret(secrets_temp)
+ self._determine_authentication_method()
+ if self.authentication_method == "service_account":
+ self._update_config_with_secret(secrets_temp)
try:
client = GoogleAdsClient.load_from_dict(self.google_ads_config)
return client.get_service("CustomerService",
version=self.api_version)
@@ -195,6 +228,22 @@ class GoogleAdsHook(BaseHook):
self.google_ads_config = conn.extra_dejson["google_ads_client"]
+ def _determine_authentication_method(self) -> None:
+ """Determine authentication method based on google_ads_config."""
+ if self.google_ads_config.get("json_key_file_path") and
self.google_ads_config.get(
+ "impersonated_email"
+ ):
+ self.authentication_method = "service_account"
+ elif (
+ self.google_ads_config.get("refresh_token")
+ and self.google_ads_config.get("client_id")
+ and self.google_ads_config.get("client_secret")
+ and self.google_ads_config.get("use_proto_plus")
+ ):
+ self.authentication_method = "developer_token"
+ else:
+ raise AirflowException("Authentication method could not be
determined")
+
def _update_config_with_secret(self, secrets_temp: IO[str]) -> None:
"""Set up Google Cloud config secret from Connection.
diff --git a/tests/providers/google/ads/hooks/test_ads.py
b/tests/providers/google/ads/hooks/test_ads.py
index 16c03b57ce..67b96c0007 100644
--- a/tests/providers/google/ads/hooks/test_ads.py
+++ b/tests/providers/google/ads/hooks/test_ads.py
@@ -21,25 +21,52 @@ from unittest.mock import PropertyMock
import pytest
+from airflow.exceptions import AirflowException
from airflow.providers.google.ads.hooks.ads import GoogleAdsHook
API_VERSION = "api_version"
-ADS_CLIENT = {"key": "value"}
+ADS_CLIENT_SERVICE_ACCOUNT = {"impersonated_email": "value",
"json_key_file_path": "value"}
SECRET = "secret"
-EXTRAS = {
+EXTRAS_SERVICE_ACCOUNT = {
"keyfile_dict": SECRET,
- "google_ads_client": ADS_CLIENT,
+ "google_ads_client": ADS_CLIENT_SERVICE_ACCOUNT,
+}
+ADS_CLIENT_DEVELOPER_TOKEN = {
+ "refresh_token": "value",
+ "client_id": "value",
+ "client_secret": "value",
+ "use_proto_plus": "value",
+}
+EXTRAS_DEVELOPER_TOKEN = {
+ "google_ads_client": ADS_CLIENT_DEVELOPER_TOKEN,
}
[email protected]()
-def mock_hook():
[email protected](
+ params=[EXTRAS_DEVELOPER_TOKEN, EXTRAS_SERVICE_ACCOUNT],
ids=["developer_token", "service_account"]
+)
+def mock_hook(request):
with mock.patch("airflow.hooks.base.BaseHook.get_connection") as conn:
hook = GoogleAdsHook(api_version=API_VERSION)
- conn.return_value.extra_dejson = EXTRAS
+ conn.return_value.extra_dejson = request.param
yield hook
[email protected](
+ params=[
+ {"input": EXTRAS_DEVELOPER_TOKEN, "expected_result":
"developer_token"},
+ {"input": EXTRAS_SERVICE_ACCOUNT, "expected_result":
"service_account"},
+ {"input": {"google_ads_client": {}}, "expected_result":
AirflowException},
+ ],
+ ids=["developer_token", "service_account", "empty"],
+)
+def mock_hook_for_authentication_method(request):
+ with mock.patch("airflow.hooks.base.BaseHook.get_connection") as conn:
+ hook = GoogleAdsHook(api_version=API_VERSION)
+ conn.return_value.extra_dejson = request.param["input"]
+ yield hook, request.param["expected_result"]
+
+
class TestGoogleAdsHook:
@mock.patch("airflow.providers.google.ads.hooks.ads.GoogleAdsClient")
def test_get_customer_service(self, mock_client, mock_hook):
@@ -87,3 +114,13 @@ class TestGoogleAdsHook:
result = mock_hook.list_accessible_customers()
service.list_accessible_customers.assert_called_once_with()
assert accounts == result
+
+ def test_determine_authentication_method(self,
mock_hook_for_authentication_method):
+ mock_hook, expected_method = mock_hook_for_authentication_method
+ mock_hook._get_config()
+ if isinstance(expected_method, type) and issubclass(expected_method,
Exception):
+ with pytest.raises(expected_method):
+ mock_hook._determine_authentication_method()
+ else:
+ mock_hook._determine_authentication_method()
+ assert mock_hook.authentication_method == expected_method