diff --git a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py index b7c1e9444..921205f41 100644 --- a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py +++ b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py @@ -69,7 +69,7 @@ def retrieve(self, key: str) -> Connection: """ spec = self._retrieve_spec(key) response = self.request(spec.method, url=spec.endpoint) - return Connection.model_validate(response.json()) + return self._select_connection(response.json(), key) @traced( name="connections_metadata", @@ -280,7 +280,7 @@ async def retrieve_async(self, key: str) -> Connection: """ spec = self._retrieve_spec(key) response = await self.request_async(spec.method, url=spec.endpoint) - return Connection.model_validate(response.json()) + return self._select_connection(response.json(), key) @traced( name="connections_metadata", @@ -571,6 +571,39 @@ def _retrieve_token_spec( params={"tokenType": token_type.value}, ) + def _select_connection(self, data: Any, key: str) -> Connection: + """Validate a single-connection retrieve response, tolerating list bodies. + + The Connections endpoint normally returns a single connection object, but it + can also answer with a list (e.g. when the key resolves to a filtered + collection). Validating a list against the single ``Connection`` model raises + ``pydantic_core.ValidationError``; instead, select the connection whose ``id`` + matches the requested key, falling back to the first entry when none match. + + Args: + data: The deserialized JSON body of the retrieve response. + key: The connection key that was requested. + + Returns: + Connection: The resolved connection. + + Raises: + ValueError: If the response is a list but contains no connections. + """ + if isinstance(data, list): + if not data: + raise ValueError(f"No connection found for key '{key}'.") + matched = next( + ( + item + for item in data + if isinstance(item, dict) and item.get("id") == key + ), + data[0], + ) + return Connection.model_validate(matched) + return Connection.model_validate(data) + def _parse_and_validate_list_response(self, response: Response) -> List[Connection]: """Parse and validate the list response from the API. diff --git a/packages/uipath-platform/tests/services/test_connections_service.py b/packages/uipath-platform/tests/services/test_connections_service.py index 27dec7310..af7e0d26c 100644 --- a/packages/uipath-platform/tests/services/test_connections_service.py +++ b/packages/uipath-platform/tests/services/test_connections_service.py @@ -94,6 +94,117 @@ def test_retrieve( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve/{version}" ) + def test_retrieve_selects_matching_connection_from_list_response( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Regression (PC-4777): tolerate a list body and select the matching connection. + + The Connections endpoint can answer with a list of connections instead of a + single object. Validating that list against the single ``Connection`` model used + to raise ``pydantic_core.ValidationError`` (input_type=list); ``retrieve`` must + instead pick the connection whose id matches the requested key. + """ + connection_key = "1958b64e-9432-47aa-aaaa-000000000002" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}", + status_code=200, + json=[ + { + "id": "1958b64e-9432-47aa-aaaa-000000000001", + "name": "Other Connection", + "elementInstanceId": 101, + }, + { + "id": connection_key, + "name": "Target Connection", + "elementInstanceId": 102, + }, + ], + ) + + connection = service.retrieve(key=connection_key) + + assert isinstance(connection, Connection) + assert connection.id == connection_key + assert connection.name == "Target Connection" + assert connection.element_instance_id == 102 + + @pytest.mark.anyio + async def test_retrieve_async_selects_matching_connection_from_list_response( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Regression (PC-4777): async retrieve tolerates a list body. + + This is the exact path from SRE-610701: ``invoke_activity_async`` -> + ``retrieve_async`` received a list and ``Connection.model_validate`` raised. + """ + connection_key = "1958b64e-9432-47aa-aaaa-000000000002" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}", + status_code=200, + json=[ + { + "id": "1958b64e-9432-47aa-aaaa-000000000001", + "name": "Other Connection", + "elementInstanceId": 101, + }, + { + "id": connection_key, + "name": "Target Connection", + "elementInstanceId": 102, + }, + ], + ) + + connection = await service.retrieve_async(key=connection_key) + + assert isinstance(connection, Connection) + assert connection.id == connection_key + assert connection.name == "Target Connection" + assert connection.element_instance_id == 102 + + def test_retrieve_falls_back_to_first_connection_when_no_key_match( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """A list with no id matching the key falls back to the first entry.""" + connection_key = "unmatched-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}", + status_code=200, + json=[ + { + "id": "first-id", + "name": "First Connection", + "elementInstanceId": 201, + }, + { + "id": "second-id", + "name": "Second Connection", + "elementInstanceId": 202, + }, + ], + ) + + connection = service.retrieve(key=connection_key) + + assert isinstance(connection, Connection) + assert connection.id == "first-id" + def test_metadata( self, httpx_mock: HTTPXMock,