From a4b422847102aaf670982f610f16d231a205adf6 Mon Sep 17 00:00:00 2001 From: uipreliga Date: Tue, 30 Sep 2025 23:56:29 +0200 Subject: [PATCH] feat(connections): inject only params present in function signature in @infer_bindings --- src/uipath/_services/connections_service.py | 221 ++++++++++- src/uipath/_uipath.py | 11 +- src/uipath/_utils/_infer_bindings.py | 6 +- .../sdk/services/test_connections_service.py | 346 +++++++++++++++++- 4 files changed, 575 insertions(+), 9 deletions(-) diff --git a/src/uipath/_services/connections_service.py b/src/uipath/_services/connections_service.py index 23adf9c12..2c1b01e79 100644 --- a/src/uipath/_services/connections_service.py +++ b/src/uipath/_services/connections_service.py @@ -1,14 +1,17 @@ import json import logging -from typing import Any, Dict +from typing import Any, Dict, List, Optional + +from httpx import Response from .._config import Config from .._execution_context import ExecutionContext -from .._utils import Endpoint, RequestSpec, infer_bindings +from .._utils import Endpoint, RequestSpec, header_folder, infer_bindings from ..models import Connection, ConnectionToken, EventArguments from ..models.connections import ConnectionTokenType from ..tracing._traced import traced from ._base_service import BaseService +from .folder_service import FolderService logger: logging.Logger = logging.getLogger("uipath") @@ -20,9 +23,16 @@ class ConnectionsService(BaseService): and secure token management. """ - def __init__(self, config: Config, execution_context: ExecutionContext) -> None: + def __init__( + self, + config: Config, + execution_context: ExecutionContext, + folders_service: FolderService, + ) -> None: super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + @infer_bindings(resource_type="connection", name="key") @traced( name="connections_retrieve", run_type="uipath", @@ -45,6 +55,117 @@ def retrieve(self, key: str) -> Connection: response = self.request(spec.method, url=spec.endpoint) return Connection.model_validate(response.json()) + @traced(name="connections_list", run_type="uipath") + def list( + self, + *, + name: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + connector_key: Optional[str] = None, + skip: Optional[int] = None, + top: Optional[int] = None, + ) -> List[Connection]: + """Lists all connections with optional filtering. + + Args: + name: Optional connection name to filter (supports partial matching) + folder_path: Optional folder path for filtering connections + folder_key: Optional folder key (mutually exclusive with folder_path) + connector_key: Optional connector key to filter by specific connector type + skip: Number of records to skip (for pagination) + top: Maximum number of records to return + + Returns: + List[Connection]: List of connection instances + + Raises: + ValueError: If both folder_path and folder_key are provided together, or if + folder_path is provided but cannot be resolved to a folder_key + + Examples: + >>> # List all connections + >>> connections = sdk.connections.list() + + >>> # Find connections by name + >>> salesforce_conns = sdk.connections.list(name="Salesforce") + + >>> # List all Slack connections in Finance folder + >>> connections = sdk.connections.list( + ... folder_path="Finance", + ... connector_key="uipath-slack" + ... ) + """ + spec = self._list_spec( + name=name, + folder_path=folder_path, + folder_key=folder_key, + connector_key=connector_key, + skip=skip, + top=top, + ) + response = self.request( + spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers + ) + + return self._parse_and_validate_list_response(response) + + @traced(name="connections_list", run_type="uipath") + async def list_async( + self, + *, + name: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + connector_key: Optional[str] = None, + skip: Optional[int] = None, + top: Optional[int] = None, + ) -> List[Connection]: + """Asynchronously lists all connections with optional filtering. + + Args: + name: Optional connection name to filter (supports partial matching) + folder_path: Optional folder path for filtering connections + folder_key: Optional folder key (mutually exclusive with folder_path) + connector_key: Optional connector key to filter by specific connector type + skip: Number of records to skip (for pagination) + top: Maximum number of records to return + + Returns: + List[Connection]: List of connection instances + + Raises: + ValueError: If both folder_path and folder_key are provided together, or if + folder_path is provided but cannot be resolved to a folder_key + + Examples: + >>> # List all connections + >>> connections = await sdk.connections.list_async() + + >>> # Find connections by name + >>> salesforce_conns = await sdk.connections.list_async(name="Salesforce") + + >>> # List all Slack connections in Finance folder + >>> connections = await sdk.connections.list_async( + ... folder_path="Finance", + ... connector_key="uipath-slack" + ... ) + """ + spec = self._list_spec( + name=name, + folder_path=folder_path, + folder_key=folder_key, + connector_key=connector_key, + skip=skip, + top=top, + ) + response = await self.request_async( + spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers + ) + + return self._parse_and_validate_list_response(response) + + @infer_bindings(resource_type="connection", name="key") @traced( name="connections_retrieve", run_type="uipath", @@ -213,3 +334,97 @@ def _retrieve_token_spec( endpoint=Endpoint(f"/connections_/api/v1/Connections/{key}/token"), params={"tokenType": token_type.value}, ) + + def _parse_and_validate_list_response(self, response: Response) -> List[Connection]: + """Parse and validate the list response from the API. + + Handles both OData response format (with 'value' field) and raw list responses. + + Args: + response: The HTTP response from the API + + Returns: + List of validated Connection instances + """ + data = response.json() + + # Handle both OData responses (dict with 'value') and raw list responses + if isinstance(data, dict): + connections_data = data.get("value", []) + elif isinstance(data, list): + connections_data = data + else: + connections_data = [] + + return [Connection.model_validate(conn) for conn in connections_data] + + def _list_spec( + self, + name: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + connector_key: Optional[str] = None, + skip: Optional[int] = None, + top: Optional[int] = None, + ) -> RequestSpec: + """Build the request specification for listing connections. + + Args: + name: Optional connection name to filter (supports partial matching) + folder_path: Optional folder path for filtering connections + folder_key: Optional folder key (mutually exclusive with folder_path) + connector_key: Optional connector key to filter by specific connector type + skip: Number of records to skip (for pagination) + top: Maximum number of records to return + + Returns: + RequestSpec with endpoint, params, and headers configured + + Raises: + ValueError: If both folder_path and folder_key are provided together, or if + folder_path is provided but cannot be resolved to a folder_key + """ + # Validate mutual exclusivity of folder_path and folder_key + if folder_path is not None and folder_key is not None: + raise ValueError( + "folder_path and folder_key are mutually exclusive and cannot be provided together" + ) + + # Resolve folder_path to folder_key if needed + resolved_folder_key = folder_key + if not resolved_folder_key and folder_path: + resolved_folder_key = self._folders_service.retrieve_key( + folder_path=folder_path + ) + if not resolved_folder_key: + raise ValueError(f"Folder with path '{folder_path}' not found") + + # Build OData filters + filters = [] + if name: + # Escape single quotes in name for OData + escaped_name = name.replace("'", "''") + filters.append(f"contains(Name, '{escaped_name}')") + if connector_key: + filters.append(f"connector/key eq '{connector_key}'") + + params = {} + if filters: + params["$filter"] = " and ".join(filters) + if skip is not None: + params["$skip"] = str(skip) + if top is not None: + params["$top"] = str(top) + + # Always expand connector and folder for complete information + params["$expand"] = "connector,folder" + + # Use header_folder which handles validation + headers = header_folder(resolved_folder_key, None) + + return RequestSpec( + method="GET", + endpoint=Endpoint("/connections_/api/v1/Connections"), + params=params, + headers=headers, + ) diff --git a/src/uipath/_uipath.py b/src/uipath/_uipath.py index c05663e7b..151772cb8 100644 --- a/src/uipath/_uipath.py +++ b/src/uipath/_uipath.py @@ -60,6 +60,7 @@ def __init__( self._folders_service: Optional[FolderService] = None self._buckets_service: Optional[BucketsService] = None self._attachments_service: Optional[AttachmentsService] = None + self._connections_service: Optional[ConnectionsService] = None setup_logging(debug) self._execution_context = ExecutionContext() @@ -98,7 +99,15 @@ def buckets(self) -> BucketsService: @property def connections(self) -> ConnectionsService: - return ConnectionsService(self._config, self._execution_context) + if not self._connections_service: + if not self._folders_service: + self._folders_service = FolderService( + self._config, self._execution_context + ) + self._connections_service = ConnectionsService( + self._config, self._execution_context, self._folders_service + ) + return self._connections_service @property def context_grounding(self) -> ContextGroundingService: diff --git a/src/uipath/_utils/_infer_bindings.py b/src/uipath/_utils/_infer_bindings.py index c0f775574..415c26eb4 100644 --- a/src/uipath/_utils/_infer_bindings.py +++ b/src/uipath/_utils/_infer_bindings.py @@ -28,8 +28,10 @@ def wrapper(*args, **kwargs): all_args.get(name), # type: ignore all_args.get(folder_path, None), ) as (name_overwrite_or_default, folder_path_overwrite_or_default): - all_args[name] = name_overwrite_or_default - all_args[folder_path] = folder_path_overwrite_or_default + if name in sig.parameters: + all_args[name] = name_overwrite_or_default + if folder_path in sig.parameters: + all_args[folder_path] = folder_path_overwrite_or_default return func(**all_args) diff --git a/tests/sdk/services/test_connections_service.py b/tests/sdk/services/test_connections_service.py index f30ad0fc0..047adfb23 100644 --- a/tests/sdk/services/test_connections_service.py +++ b/tests/sdk/services/test_connections_service.py @@ -1,19 +1,38 @@ +from unittest.mock import MagicMock +from urllib.parse import unquote_plus + import pytest from pytest_httpx import HTTPXMock from uipath._config import Config from uipath._execution_context import ExecutionContext from uipath._services.connections_service import ConnectionsService -from uipath._utils.constants import HEADER_USER_AGENT +from uipath._services.folder_service import FolderService +from uipath._utils.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.models import Connection, ConnectionToken, EventArguments +@pytest.fixture +def mock_folders_service() -> MagicMock: + """Mock FolderService for testing.""" + service = MagicMock(spec=FolderService) + service.retrieve_key.return_value = "test-folder-key" + return service + + @pytest.fixture def service( - config: Config, execution_context: ExecutionContext, monkeypatch: pytest.MonkeyPatch + config: Config, + execution_context: ExecutionContext, + mock_folders_service: MagicMock, + monkeypatch: pytest.MonkeyPatch, ) -> ConnectionsService: monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return ConnectionsService(config=config, execution_context=execution_context) + return ConnectionsService( + config=config, + execution_context=execution_context, + folders_service=mock_folders_service, + ) class TestConnectionsService: @@ -195,6 +214,251 @@ async def test_retrieve_token_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_token_async/{version}" ) + def test_list_no_filters( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method without any filters.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-1", + "name": "Slack Connection", + "state": "active", + "elementInstanceId": 101, + }, + { + "id": "conn-2", + "name": "Salesforce Connection", + "state": "active", + "elementInstanceId": 102, + }, + ] + }, + ) + + connections = service.list() + + assert isinstance(connections, list) + assert len(connections) == 2 + assert connections[0].id == "conn-1" + assert connections[0].name == "Slack Connection" + assert connections[1].id == "conn-2" + assert connections[1].name == "Salesforce Connection" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + # Check for URL-encoded version + assert "%24expand=connector%2Cfolder" in str(sent_request.url) + + def test_list_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with name filtering.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=contains%28Name%2C%20%27Salesforce%27%29&%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-2", + "name": "Salesforce Connection", + "state": "active", + "elementInstanceId": 102, + } + ] + }, + ) + + connections = service.list(name="Salesforce") + + assert len(connections) == 1 + assert connections[0].name == "Salesforce Connection" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Decode URL-encoded characters (including + as space) + url_str = unquote_plus(str(sent_request.url)) + assert "contains(Name, 'Salesforce')" in url_str + + def test_list_with_folder_path_resolution( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + mock_folders_service: MagicMock, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with folder path resolution.""" + mock_folders_service.retrieve_key.return_value = "folder-123" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json={"value": []}, + ) + + service.list(folder_path="Finance/Production") + + # Verify folder service was called + mock_folders_service.retrieve_key.assert_called_once_with( + folder_path="Finance/Production" + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Verify the resolved key was used in headers + assert HEADER_FOLDER_KEY in sent_request.headers + assert sent_request.headers[HEADER_FOLDER_KEY] == "folder-123" + + def test_list_with_connector_filter( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with connector key filtering.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=connector%2Fkey%20eq%20%27uipath-slack%27&%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-1", + "name": "Slack Connection", + "state": "active", + "elementInstanceId": 101, + } + ] + }, + ) + + connections = service.list(connector_key="uipath-slack") + + assert len(connections) == 1 + assert connections[0].name == "Slack Connection" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Decode URL-encoded characters (including + as space) + url_str = unquote_plus(str(sent_request.url)) + assert "connector/key eq 'uipath-slack'" in url_str + + def test_list_with_pagination( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with pagination parameters.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24skip=10&%24top=5&%24expand=connector%2Cfolder", + status_code=200, + json={"value": []}, + ) + + service.list(skip=10, top=5) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert "%24skip=10" in str(sent_request.url) + assert "%24top=5" in str(sent_request.url) + + def test_list_with_combined_filters( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + mock_folders_service: MagicMock, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with multiple filters combined.""" + mock_folders_service.retrieve_key.return_value = "folder-456" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=contains%28Name%2C%20%27Slack%27%29%20and%20connector%2Fkey%20eq%20%27uipath-slack%27&%24expand=connector%2Cfolder", + status_code=200, + json={"value": []}, + ) + + service.list(name="Slack", folder_path="Finance", connector_key="uipath-slack") + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Decode URL-encoded characters (including + as space) + url_str = unquote_plus(str(sent_request.url)) + assert "contains(Name, 'Slack')" in url_str + assert "connector/key eq 'uipath-slack'" in url_str + assert " and " in url_str + + @pytest.mark.anyio + async def test_list_async( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test async version of list method.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-1", + "name": "Test Connection", + "state": "active", + "elementInstanceId": 101, + } + ] + }, + ) + + connections = await service.list_async() + + assert len(connections) == 1 + assert connections[0].name == "Test Connection" + def test_retrieve_event_payload( self, httpx_mock: HTTPXMock, @@ -416,3 +680,79 @@ async def test_retrieve_event_payload_async_missing_event_id( ValueError, match="Event Id not found in additional event data" ): await service.retrieve_event_payload_async(event_args=event_args) + + def test_list_with_invalid_folder_path( + self, service: ConnectionsService, mock_folders_service: MagicMock + ) -> None: + """Test that ValueError is raised when folder_path cannot be resolved.""" + mock_folders_service.retrieve_key.return_value = None + + with pytest.raises(ValueError) as exc_info: + service.list(folder_path="NonExistent/Folder") + + assert "Folder with path 'NonExistent/Folder' not found" in str(exc_info.value) + mock_folders_service.retrieve_key.assert_called_once_with( + folder_path="NonExistent/Folder" + ) + + def test_list_with_name_containing_quote( + self, httpx_mock: HTTPXMock, service: ConnectionsService + ) -> None: + """Test that names with quotes are properly escaped.""" + httpx_mock.add_response(json={"value": []}) + + service.list(name="O'Malley") + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Verify the single quote was doubled (escaped) in the OData filter + # The URL should contain O''Malley (with doubled single quote) + url_str = str(sent_request.url) + # Check that the filter contains the escaped quote + assert "O%27%27Malley" in url_str or "O''Malley" in url_str.replace( + "%27%27", "''" + ) + + def test_list_with_raw_list_response( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that list method handles raw list responses (not wrapped in 'value').""" + # Some API endpoints return a raw list instead of OData format + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json=[ + { + "id": "conn-1", + "name": "Direct List Connection", + "state": "active", + "elementInstanceId": 101, + } + ], + ) + + connections = service.list() + + assert isinstance(connections, list) + assert len(connections) == 1 + assert connections[0].id == "conn-1" + assert connections[0].name == "Direct List Connection" + + def test_list_with_both_folder_path_and_folder_key_raises_error( + self, service: ConnectionsService + ) -> None: + """Test that providing both folder_path and folder_key raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + service.list(folder_path="Finance/Production", folder_key="folder-123") + + assert "folder_path and folder_key are mutually exclusive" in str( + exc_info.value + ) + assert "cannot be provided together" in str(exc_info.value)