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

Reply via email to