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