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 74abe586d7f Zendesk: support API & OAuth tokens; unhide extra in 
Connection UI (#64591)
74abe586d7f is described below

commit 74abe586d7fba6c30597fa0495bbc820a97a87ae
Author: Subham <[email protected]>
AuthorDate: Tue May 12 04:47:18 2026 +0530

    Zendesk: support API & OAuth tokens; unhide extra in Connection UI (#64591)
---
 providers/zendesk/provider.yaml                    |  26 ++-
 .../airflow/providers/zendesk/get_provider_info.py |  25 ++-
 .../src/airflow/providers/zendesk/hooks/zendesk.py | 182 ++++++++++++++++++---
 .../tests/unit/zendesk/hooks/test_zendesk.py       | 164 ++++++++++++++++++-
 4 files changed, 366 insertions(+), 31 deletions(-)

diff --git a/providers/zendesk/provider.yaml b/providers/zendesk/provider.yaml
index 925648ee50a..532a8546c70 100644
--- a/providers/zendesk/provider.yaml
+++ b/providers/zendesk/provider.yaml
@@ -80,7 +80,31 @@ connection-types:
       hidden-fields:
         - schema
         - port
-        - extra
       relabeling:
         host: Zendesk domain
         login: Zendesk email
+        password: Password / API token
+    conn-fields:
+      use_token:
+        label: Use Token
+        schema:
+          type:
+            - boolean
+            - 'null'
+        description: If enabled, the password field is treated as an API token.
+      token:
+        label: API Token
+        schema:
+          type:
+            - string
+            - 'null'
+          format: password
+        description: Zendesk API token (alternative to password field).
+      oauth_token:
+        label: OAuth Token
+        schema:
+          type:
+            - string
+            - 'null'
+          format: password
+        description: Zendesk OAuth token.
diff --git 
a/providers/zendesk/src/airflow/providers/zendesk/get_provider_info.py 
b/providers/zendesk/src/airflow/providers/zendesk/get_provider_info.py
index 3a6cdd295ce..6d9602cc522 100644
--- a/providers/zendesk/src/airflow/providers/zendesk/get_provider_info.py
+++ b/providers/zendesk/src/airflow/providers/zendesk/get_provider_info.py
@@ -43,8 +43,29 @@ def get_provider_info():
                 "hook-name": "Zendesk",
                 "connection-type": "zendesk",
                 "ui-field-behaviour": {
-                    "hidden-fields": ["schema", "port", "extra"],
-                    "relabeling": {"host": "Zendesk domain", "login": "Zendesk 
email"},
+                    "hidden-fields": ["schema", "port"],
+                    "relabeling": {
+                        "host": "Zendesk domain",
+                        "login": "Zendesk email",
+                        "password": "Password / API token",
+                    },
+                },
+                "conn-fields": {
+                    "use_token": {
+                        "label": "Use Token",
+                        "schema": {"type": ["boolean", "null"]},
+                        "description": "If enabled, the password field is 
treated as an API token.",
+                    },
+                    "token": {
+                        "label": "API Token",
+                        "schema": {"type": ["string", "null"], "format": 
"password"},
+                        "description": "Zendesk API token (alternative to 
password field).",
+                    },
+                    "oauth_token": {
+                        "label": "OAuth Token",
+                        "schema": {"type": ["string", "null"], "format": 
"password"},
+                        "description": "Zendesk OAuth token.",
+                    },
                 },
             }
         ],
diff --git a/providers/zendesk/src/airflow/providers/zendesk/hooks/zendesk.py 
b/providers/zendesk/src/airflow/providers/zendesk/hooks/zendesk.py
index dc8936ad693..a1bb59595cd 100644
--- a/providers/zendesk/src/airflow/providers/zendesk/hooks/zendesk.py
+++ b/providers/zendesk/src/airflow/providers/zendesk/hooks/zendesk.py
@@ -1,4 +1,3 @@
-#
 # Licensed to the Apache Software Foundation (ASF) under one
 # or more contributor license agreements.  See the NOTICE file
 # distributed with this work for additional information
@@ -15,8 +14,10 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+
 from __future__ import annotations
 
+from functools import cached_property
 from typing import TYPE_CHECKING, Any
 
 from zenpy import Zenpy
@@ -34,6 +35,22 @@ class ZendeskHook(BaseHook):
     Interact with Zendesk. This hook uses the Zendesk conn_id.
 
     :param zendesk_conn_id: The Airflow connection used for Zendesk 
credentials.
+
+    Authentication modes (configured via Connection extras):
+
+    - **API token** (recommended): Set ``token`` in the extra field to your 
Zendesk
+      API token. The ``login`` field should be your email address.
+    - **API token via password field**: Set ``use_token: true`` in extras and 
put
+      the API token in the ``password`` field. Useful when managing secrets via
+      environment variables. ``login`` should be your email address.
+    - **OAuth token**: Set ``oauth_token`` in the extra field. ``login`` is not
+      required for OAuth.
+    - **Password** (deprecated): If none of the above extras are set, the
+      ``password`` field is used for basic authentication. Zendesk has 
deprecated
+      this method; prefer API token auth.
+
+    Precedence order when multiple extras are set: ``use_token`` → ``token`` →
+    ``oauth_token`` → password fallback.
     """
 
     conn_name_attr = "zendesk_conn_id"
@@ -43,54 +60,167 @@ class ZendeskHook(BaseHook):
 
     @classmethod
     def get_ui_field_behaviour(cls) -> dict[str, Any]:
+        """Relabel fields for the Connection UI."""
         return {
-            "hidden_fields": ["schema", "port", "extra"],
-            "relabeling": {"host": "Zendesk domain", "login": "Zendesk email"},
+            "hidden_fields": ["schema", "port"],
+            "relabeling": {
+                "host": "Zendesk domain",
+                "login": "Zendesk email",
+                "password": "Password / API token",
+            },
+        }
+
+    @classmethod
+    def get_connection_form_widgets(cls) -> dict[str, Any]:
+        """Add custom widgets for the Connection UI."""
+        from flask_appbuilder.fieldwidgets import BS3PasswordFieldWidget
+        from wtforms import BooleanField, StringField
+
+        return {
+            "use_token": BooleanField(
+                "Use Token", description="If enabled, the password field is 
treated as an API token."
+            ),
+            "token": StringField(
+                "API Token",
+                widget=BS3PasswordFieldWidget(),
+                description="Zendesk API token (alternative to password 
field).",
+            ),
+            "oauth_token": StringField(
+                "OAuth Token",
+                widget=BS3PasswordFieldWidget(),
+                description="Zendesk OAuth token.",
+            ),
         }
 
     def __init__(self, zendesk_conn_id: str = default_conn_name) -> None:
         super().__init__()
         self.zendesk_conn_id = zendesk_conn_id
         self.base_api: BaseApi | None = None
-        zenpy_client, url = self._init_conn()
-        self.zenpy_client = zenpy_client
-        self.__url = url
-        self.get = self.zenpy_client.users._get
+        self.__url: str = ""
 
     def _init_conn(self) -> tuple[Zenpy, str]:
         """
-        Create the Zenpy Client for our Zendesk connection.
+        Create the Zenpy Client for the Zendesk connection.
+
+        Parses the host into ``domain`` and (optionally) ``subdomain`` for 
Zenpy.
+        For example, ``yoursubdomain.zendesk.com`` produces
+        ``domain="zendesk.com"`` and ``subdomain="yoursubdomain"``.
+
+        Authentication kwargs are resolved from Connection extras according to
+        the precedence documented on the class docstring.
 
-        :return: zenpy.Zenpy client and the url for the API.
+        :return: (zenpy.Zenpy client, base URL string)
+        :raises ValueError: if the host is missing or has an invalid format.
         """
         conn = self.get_connection(self.zendesk_conn_id)
-        domain = ""
-        url = ""
-        subdomain: str | None = None
-        if conn.host:
-            url = "https://"; + conn.host
-            domain = conn.host
-            if conn.host.count(".") >= 2:
-                dot_splitted_string = conn.host.rsplit(".", 2)
-                subdomain = dot_splitted_string[0]
-                domain = ".".join(dot_splitted_string[1:])
-        return Zenpy(domain=domain, subdomain=subdomain, email=conn.login, 
password=conn.password), url
+
+        if not conn.host:
+            raise ValueError(
+                f"No host provided for connection '{self.zendesk_conn_id}'. "
+                "Set the host to your Zendesk domain, e.g. 
'yoursubdomain.zendesk.com'."
+            )
+
+        # Parse host into subdomain + domain.
+        # Handle trailing dots and extract domain (last two parts) and 
subdomain (the rest).
+        host = conn.host.strip("/")
+        if host.endswith("."):
+            host = host[:-1]
+
+        parts = host.split(".")
+        if len(parts) < 2:
+            raise ValueError(
+                f"Invalid host format '{conn.host}' for connection 
'{self.zendesk_conn_id}'. "
+                "Expected a domain with at least one dot, e.g. 
'yoursubdomain.zendesk.com'."
+            )
+
+        domain = ".".join(parts[-2:])
+        subdomain: str | None = ".".join(parts[:-2]) if len(parts) > 2 else 
None
+        url = f"https://{host}";
+
+        extra = conn.extra_dejson
+        kwargs: dict[str, Any] = {
+            "domain": domain,
+            "subdomain": subdomain,
+        }
+
+        if extra.get("use_token"):
+            # Treat the password field as an API token.
+            if not conn.login:
+                raise ValueError(
+                    f"No login provided for connection 
'{self.zendesk_conn_id}'. "
+                    "The login field must be set to your Zendesk email address 
when using API token "
+                    "authentication."
+                )
+            kwargs["email"] = conn.login
+            kwargs["token"] = conn.password
+        elif extra.get("token"):
+            # API token stored directly in extras.
+            if not conn.login:
+                raise ValueError(
+                    f"No login provided for connection 
'{self.zendesk_conn_id}'. "
+                    "The login field must be set to your Zendesk email address 
when using API token "
+                    "authentication."
+                )
+            kwargs["email"] = conn.login
+            kwargs["token"] = extra["token"]
+        elif extra.get("oauth_token"):
+            # OAuth token stored in extras. email is NOT required.
+            kwargs["oauth_token"] = extra["oauth_token"]
+        else:
+            # Legacy password-based auth (deprecated by Zendesk).
+            if not conn.login:
+                raise ValueError(
+                    f"No login provided for connection 
'{self.zendesk_conn_id}'. "
+                    "The login field must be set to your Zendesk email address 
when using password "
+                    "authentication."
+                )
+            kwargs["email"] = conn.login
+            kwargs["password"] = conn.password
+
+        return Zenpy(**kwargs), url
+
+    @cached_property
+    def zenpy_client(self) -> Zenpy:
+        """
+        Get the underlying Zenpy client (cached property for backward 
compatibility).
+
+        :return: zenpy.Zenpy client.
+        """
+        client, self.__url = self._init_conn()
+        return client
+
+    @property
+    def _url(self) -> str:
+        """Return the base URL, initializing the connection if needed."""
+        if not self.__url:
+            # Accessing zenpy_client triggers _init_conn which sets __url
+            _ = self.zenpy_client
+        return self.__url
 
     def get_conn(self) -> Zenpy:
         """
-        Get the underlying Zenpy client.
+        Get the underlying Zenpy client (lazy-initialized).
 
         :return: zenpy.Zenpy client.
         """
         return self.zenpy_client
 
+    @property
+    def get(self) -> Any:
+        """
+        Expose the underlying Zenpy search/get method for backward 
compatibility.
+
+        Used by system tests and legacy custom calls.
+        """
+        return self.get_conn().users._get
+
     def get_ticket(self, ticket_id: int) -> Ticket:
         """
         Retrieve ticket.
 
         :return: Ticket object retrieved.
         """
-        return self.zenpy_client.tickets(id=ticket_id)
+        return self.get_conn().tickets(id=ticket_id)
 
     def search_tickets(self, **kwargs) -> SearchResultGenerator:
         """
@@ -99,7 +229,7 @@ class ZendeskHook(BaseHook):
         :param kwargs: (optional) Search fields given to the zenpy search 
method.
         :return: SearchResultGenerator of Ticket objects.
         """
-        return self.zenpy_client.search(type="ticket", **kwargs)
+        return self.get_conn().search(type="ticket", **kwargs)
 
     def create_tickets(self, tickets: Ticket | list[Ticket], **kwargs) -> 
TicketAudit | JobStatus:
         """
@@ -110,7 +240,7 @@ class ZendeskHook(BaseHook):
         :return: A TicketAudit object containing information about the Ticket 
created.
             When sending bulk request, returns a JobStatus object.
         """
-        return self.zenpy_client.tickets.create(tickets, **kwargs)
+        return self.get_conn().tickets.create(tickets, **kwargs)
 
     def update_tickets(self, tickets: Ticket | list[Ticket], **kwargs) -> 
TicketAudit | JobStatus:
         """
@@ -121,7 +251,7 @@ class ZendeskHook(BaseHook):
         :return: A TicketAudit object containing information about the Ticket 
updated.
             When sending bulk request, returns a JobStatus object.
         """
-        return self.zenpy_client.tickets.update(tickets, **kwargs)
+        return self.get_conn().tickets.update(tickets, **kwargs)
 
     def delete_tickets(self, tickets: Ticket | list[Ticket], **kwargs) -> None:
         """
@@ -131,4 +261,4 @@ class ZendeskHook(BaseHook):
         :param kwargs: (optional) Additional fields given to the zenpy delete 
method.
         :return:
         """
-        return self.zenpy_client.tickets.delete(tickets, **kwargs)
+        return self.get_conn().tickets.delete(tickets, **kwargs)
diff --git a/providers/zendesk/tests/unit/zendesk/hooks/test_zendesk.py 
b/providers/zendesk/tests/unit/zendesk/hooks/test_zendesk.py
index 6b00eb62686..e4769e09d8b 100644
--- a/providers/zendesk/tests/unit/zendesk/hooks/test_zendesk.py
+++ b/providers/zendesk/tests/unit/zendesk/hooks/test_zendesk.py
@@ -1,4 +1,3 @@
-#
 # Licensed to the Apache Software Foundation (ASF) under one
 # or more contributor license agreements.  See the NOTICE file
 # distributed with this work for additional information
@@ -15,8 +14,10 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+
 from __future__ import annotations
 
+import json
 from unittest.mock import patch
 
 import pytest
@@ -49,7 +50,147 @@ class TestZendeskHook:
         assert zenpy_client.users.domain == "zendesk.com"
         assert zenpy_client.users.session.auth == ("[email protected]", 
"eb243592-faa2-4ba2-a551q-1afdf565c889")
         assert not zenpy_client.cache.disabled
-        assert self.hook._ZendeskHook__url == 
"https://yoursubdomain.zendesk.com";
+        assert self.hook._url == "https://yoursubdomain.zendesk.com";
+
+    def test_get_conn_is_lazy_and_cached(self):
+        """get_conn() should return the same client instance on repeated 
calls."""
+        client1 = self.hook.get_conn()
+        client2 = self.hook.get_conn()
+        assert client1 is client2
+
+    # ------------------------------------------------------------------
+    # Authentication mode tests
+    # ------------------------------------------------------------------
+
+    @pytest.mark.parametrize(
+        ("host", "expected_subdomain", "expected_domain"),
+        [
+            ("yoursubdomain.zendesk.com", "yoursubdomain", "zendesk.com"),
+            ("zendesk.com", None, "zendesk.com"),
+            ("sub.company.zendesk.com", "sub.company", "zendesk.com"),
+        ],
+    )
+    def test_host_parsing(self, create_connection_without_db, host, 
expected_subdomain, expected_domain):
+        conn_id = f"zendesk_host_test_{host.replace('.', '_')}"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host=host,
+                login="[email protected]",
+                password="secret",
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        client = hook.get_conn()
+        assert client.users.subdomain == expected_subdomain
+        assert client.users.domain == expected_domain
+
+    def test_invalid_host_no_dot_raises_value_error(self, 
create_connection_without_db):
+        """A host with no dot (e.g. just 'zendesk') must raise ValueError."""
+        conn_id = "zendesk_bad_host"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host="zendesk",
+                login="[email protected]",
+                password="secret",
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        with pytest.raises(ValueError, match="Invalid host format"):
+            hook.get_conn()
+
+    def test_missing_host_raises_value_error(self, 
create_connection_without_db):
+        """A connection with no host must raise ValueError."""
+        conn_id = "zendesk_no_host"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host=None,
+                login="[email protected]",
+                password="secret",
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        with pytest.raises(ValueError, match="No host provided"):
+            hook.get_conn()
+
+    def test_auth_use_token_flag(self, create_connection_without_db):
+        """use_token=true in extras should pass conn.password as the API 
token."""
+        conn_id = "zendesk_use_token"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host="yoursubdomain.zendesk.com",
+                login="[email protected]",
+                password="my-api-token",
+                extra=json.dumps({"use_token": True}),
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        client = hook.get_conn()
+        # Zenpy encodes token auth as "<email>/token:<token>"
+        assert client.users.session.auth == ("[email protected]/token", 
"my-api-token")
+
+    def test_auth_token_in_extra(self, create_connection_without_db):
+        """A 'token' key in extras should be used as the API token directly."""
+        conn_id = "zendesk_token_extra"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host="yoursubdomain.zendesk.com",
+                login="[email protected]",
+                extra=json.dumps({"token": "extra-api-token"}),
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        client = hook.get_conn()
+        assert client.users.session.auth == ("[email protected]/token", 
"extra-api-token")
+
+    def test_auth_oauth_token_in_extra(self, create_connection_without_db):
+        """An 'oauth_token' key in extras should configure OAuth 
authentication."""
+        conn_id = "zendesk_oauth"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host="yoursubdomain.zendesk.com",
+                login="[email protected]",
+                extra=json.dumps({"oauth_token": "my-oauth-token"}),
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        client = hook.get_conn()
+        # Zenpy sets a Bearer token header for OAuth
+        assert "Authorization" in client.users.session.headers
+        assert client.users.session.headers["Authorization"] == "Bearer 
my-oauth-token"
+
+    def test_auth_precedence_use_token_over_token_extra(self, 
create_connection_without_db):
+        """use_token flag takes precedence over a token key in extras."""
+        conn_id = "zendesk_precedence"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host="yoursubdomain.zendesk.com",
+                login="[email protected]",
+                password="password-field-token",
+                extra=json.dumps({"use_token": True, "token": 
"should-be-ignored"}),
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        client = hook.get_conn()
+        # use_token takes priority: password field is the token
+        assert client.users.session.auth == ("[email protected]/token", 
"password-field-token")
+
+    # ------------------------------------------------------------------
+    # Ticket operation tests
+    # ------------------------------------------------------------------
 
     def test_get_ticket(self):
         zenpy_client = self.hook.get_conn()
@@ -83,3 +224,22 @@ class TestZendeskHook:
         with patch.object(zenpy_client.tickets, "delete") as search_mock:
             self.hook.delete_tickets(ticket, extra_parameter="extra_parameter")
             search_mock.assert_called_once_with(ticket, 
extra_parameter="extra_parameter")
+
+    def test_auth_oauth_token_no_login(self, create_connection_without_db):
+        """OAuth authentication should not require a login/email."""
+        conn_id = "zendesk_oauth_no_login"
+        create_connection_without_db(
+            Connection(
+                conn_id=conn_id,
+                conn_type="zendesk",
+                host="yoursubdomain.zendesk.com",
+                login=None,
+                extra=json.dumps({"oauth_token": "my-oauth-token"}),
+            )
+        )
+        hook = ZendeskHook(zendesk_conn_id=conn_id)
+        client = hook.get_conn()
+        assert "Authorization" in client.users.session.headers
+        assert client.users.session.headers["Authorization"] == "Bearer 
my-oauth-token"
+        # email/login should not be present in kwargs passed to Zenpy
+        assert not hasattr(client.users, "email") or client.users.email is None

Reply via email to