Repository: GoogleCloudPlatform/cloud-sql-python-connector (python)

Issue:
Add option to configure SQL Admin API URL
Add ability to specify SQL Admin API endpoint with Python Connector.

Context: https://github.com/GoogleCloudPlatform/cloud-sql-go-connector/pull/148


Pull request description:
feat: add `sqladmin_api_endpoint` arg to specify Cloud SQL Admin API endpoint
Adding `sqladmin_api_endpoint` arg to `Connector` to allow specifying different Cloud SQL Admin API base URL endpoint. This feature is meant to be used in development environments.

Closes #468 

Patch:
diff --git a/google/cloud/sql/connector/connector.py b/google/cloud/sql/connector/connector.py
index ed4afb1..e4ff716 100755
--- a/google/cloud/sql/connector/connector.py
+++ b/google/cloud/sql/connector/connector.py
@@ -76,6 +76,12 @@ class Connector:
     :param loop
         Event loop to run asyncio tasks, if not specified, defaults to
         creating new event loop on background thread.
+
+    :type sqladmin_api_endpoint: str
+    :param sqladmin_api_endpoint:
+        Base URL to use when calling the Cloud SQL Admin API endpoint.
+        Defaults to "https://sqladmin.googleapis.com", this argument should
+        only be used in development.
     """
 
     def __init__(
@@ -86,6 +92,7 @@ class Connector:
         credentials: Optional[Credentials] = None,
         loop: asyncio.AbstractEventLoop = None,
         quota_project: Optional[str] = None,
+        sqladmin_api_endpoint: str = "https://sqladmin.googleapis.com",
     ) -> None:
         # if event loop is given, use for background tasks
         if loop:
@@ -108,6 +115,7 @@ class Connector:
         self._enable_iam_auth = enable_iam_auth
         self._ip_type = ip_type
         self._quota_project = quota_project
+        self._sqladmin_api_endpoint = sqladmin_api_endpoint
         self._credentials = credentials
 
     def connect(
@@ -204,6 +212,7 @@ class Connector:
                 self._credentials,
                 enable_iam_auth,
                 self._quota_project,
+                self._sqladmin_api_endpoint,
             )
             self._instances[instance_connection_string] = instance
 
diff --git a/google/cloud/sql/connector/instance.py b/google/cloud/sql/connector/instance.py
index 0f55fbf..31f5eed 100644
--- a/google/cloud/sql/connector/instance.py
+++ b/google/cloud/sql/connector/instance.py
@@ -195,6 +195,12 @@ class Instance:
         The Project ID for an existing Google Cloud project. The project specified
         is used for quota and billing purposes. If not specified, defaults to
         project sourced from environment.
+
+    :type sqladmin_api_endpoint: str
+    :param sqladmin_api_endpoint:
+        Base URL to use when calling the Cloud SQL Admin API endpoint.
+        Defaults to "https://sqladmin.googleapis.com", this argument should
+        only be used in development.
     """
 
     # asyncio.AbstractEventLoop is used because the default loop,
@@ -227,6 +233,7 @@ class Instance:
 
     _instance_connection_string: str
     _user_agent_string: str
+    _sqladmin_api_endpoint: str
     _instance: str
     _project: str
     _region: str
@@ -245,6 +252,7 @@ class Instance:
         credentials: Optional[Credentials] = None,
         enable_iam_auth: bool = False,
         quota_project: str = None,
+        sqladmin_api_endpoint: str = "https://sqladmin.googleapis.com",
     ) -> None:
         # Validate connection string
         connection_string_split = instance_connection_string.split(":")
@@ -265,6 +273,7 @@ class Instance:
 
         self._user_agent_string = f"{APPLICATION_NAME}/{version}+{driver_name}"
         self._quota_project = quota_project
+        self._sqladmin_api_endpoint = sqladmin_api_endpoint
         self._loop = loop
         self._keys = keys
         # validate credentials type
@@ -339,6 +348,7 @@ class Instance:
             metadata_task = self._loop.create_task(
                 _get_metadata(
                     self._client_session,
+                    self._sqladmin_api_endpoint,
                     self._credentials,
                     self._project,
                     self._instance,
@@ -348,6 +358,7 @@ class Instance:
             ephemeral_task = self._loop.create_task(
                 _get_ephemeral(
                     self._client_session,
+                    self._sqladmin_api_endpoint,
                     self._credentials,
                     self._project,
                     self._instance,
diff --git a/google/cloud/sql/connector/refresh_utils.py b/google/cloud/sql/connector/refresh_utils.py
index 2b55e2e..6f4b237 100644
--- a/google/cloud/sql/connector/refresh_utils.py
+++ b/google/cloud/sql/connector/refresh_utils.py
@@ -42,6 +42,7 @@ _iam_auth_refresh_buffer: int = 55  # seconds
 
 async def _get_metadata(
     client_session: aiohttp.ClientSession,
+    sqladmin_api_endpoint: str,
     credentials: Credentials,
     project: str,
     instance: str,
@@ -50,6 +51,10 @@ async def _get_metadata(
     and returns a dictionary containing the IP addresses and certificate
     authority of the Cloud SQL Instance.
 
+    :type sqladmin_api_endpoint: str
+    :param sqladmin_api_endpoint:
+        Base URL to use when calling the Cloud SQL Admin API endpoint.
+
     :type credentials: google.auth.credentials.Credentials
     :param credentials:
         A credentials object created from the google-auth Python library.
@@ -89,8 +94,8 @@ async def _get_metadata(
         "Authorization": "Bearer {}".format(credentials.token),
     }
 
-    url = "https://sqladmin.googleapis.com/sql/{}/projects/{}/instances/{}/connectSettings".format(
-        _sql_api_version, project, instance
+    url = "{}/sql/{}/projects/{}/instances/{}/connectSettings".format(
+        sqladmin_api_endpoint, _sql_api_version, project, instance
     )
 
     logger.debug(f"['{instance}']: Requesting metadata")
@@ -108,6 +113,7 @@ async def _get_metadata(
 
 async def _get_ephemeral(
     client_session: aiohttp.ClientSession,
+    sqladmin_api_endpoint: str,
     credentials: Credentials,
     project: str,
     instance: str,
@@ -116,6 +122,10 @@ async def _get_ephemeral(
 ) -> str:
     """Asynchronously requests an ephemeral certificate from the Cloud SQL Instance.
 
+    :type sqladmin_api_endpoint: str
+    :param sqladmin_api_endpoint:
+        Base URL to use when calling the Cloud SQL Admin API endpoint.
+
     :type credentials: google.auth.credentials.Credentials
     :param credentials: A credentials object
         created from the google-auth library. Must be
@@ -164,8 +174,8 @@ async def _get_ephemeral(
         "Authorization": f"Bearer {credentials.token}",
     }
 
-    url = "https://sqladmin.googleapis.com/sql/{}/projects/{}/instances/{}:generateEphemeralCert".format(
-        _sql_api_version, project, instance
+    url = "{}/sql/{}/projects/{}/instances/{}:generateEphemeralCert".format(
+        sqladmin_api_endpoint, _sql_api_version, project, instance
     )
 
     data = {"public_key": pub_key}


Test patch:
diff --git a/tests/unit/test_refresh_utils.py b/tests/unit/test_refresh_utils.py
index e05bce6..010c059 100644
--- a/tests/unit/test_refresh_utils.py
+++ b/tests/unit/test_refresh_utils.py
@@ -66,7 +66,12 @@ async def test_get_ephemeral(
         )
         async with aiohttp.ClientSession() as client_session:
             result: Any = await _get_ephemeral(
-                client_session, credentials, project, instance, pub_key
+                client_session,
+                "https://sqladmin.googleapis.com",
+                credentials,
+                project,
+                instance,
+                pub_key,
             )
     result = result.strip()  # remove any trailing whitespace
     result = result.split("\n")
@@ -93,6 +98,7 @@ async def test_get_ephemeral_TypeError(credentials: Credentials) -> None:
     with pytest.raises(TypeError):
         await _get_ephemeral(
             client_session=client_session,
+            sqladmin_api_endpoint="https://sqladmin.googleapis.com",
             credentials="bad-credentials",
             project=project,
             instance=instance,
@@ -102,6 +108,7 @@ async def test_get_ephemeral_TypeError(credentials: Credentials) -> None:
     with pytest.raises(TypeError):
         await _get_ephemeral(
             client_session=client_session,
+            sqladmin_api_endpoint="https://sqladmin.googleapis.com",
             credentials=credentials,
             project=12345,
             instance=instance,
@@ -111,6 +118,7 @@ async def test_get_ephemeral_TypeError(credentials: Credentials) -> None:
     with pytest.raises(TypeError):
         await _get_ephemeral(
             client_session=client_session,
+            sqladmin_api_endpoint="https://sqladmin.googleapis.com",
             credentials=credentials,
             project=project,
             instance=12345,
@@ -120,6 +128,7 @@ async def test_get_ephemeral_TypeError(credentials: Credentials) -> None:
     with pytest.raises(TypeError):
         await _get_ephemeral(
             client_session=client_session,
+            sqladmin_api_endpoint="https://sqladmin.googleapis.com",
             credentials=credentials,
             project=project,
             instance=instance,
@@ -147,7 +156,13 @@ async def test_get_metadata(
         )
 
         async with aiohttp.ClientSession() as client_session:
-            result = await _get_metadata(client_session, credentials, project, instance)
+            result = await _get_metadata(
+                client_session,
+                "https://sqladmin.googleapis.com",
+                credentials,
+                project,
+                instance,
+            )
 
     assert result["ip_addresses"] is not None and isinstance(
         result["server_ca_cert"], str
@@ -169,6 +184,7 @@ async def test_get_metadata_TypeError(credentials: Credentials) -> None:
     with pytest.raises(TypeError):
         await _get_metadata(
             client_session=client_session,
+            sqladmin_api_endpoint="https://sqladmin.googleapis.com",
             credentials="bad-credentials",
             project=project,
             instance=instance,
@@ -177,6 +193,7 @@ async def test_get_metadata_TypeError(credentials: Credentials) -> None:
     with pytest.raises(TypeError):
         await _get_metadata(
             client_session=client_session,
+            sqladmin_api_endpoint="https://sqladmin.googleapis.com",
             credentials=credentials,
             project=12345,
             instance=instance,
@@ -185,6 +202,7 @@ async def test_get_metadata_TypeError(credentials: Credentials) -> None:
     with pytest.raises(TypeError):
         await _get_metadata(
             client_session=client_session,
+            sqladmin_api_endpoint="https://sqladmin.googleapis.com",
             credentials=credentials,
             project=project,
             instance=12345,