diff --git a/pyatlan/client/constants.py b/pyatlan/client/constants.py index 5edd57b46..e1e2009b1 100644 --- a/pyatlan/client/constants.py +++ b/pyatlan/client/constants.py @@ -488,6 +488,9 @@ HTTPStatus.OK, endpoint=EndPoint.HERACLES, ) +GET_ALL_CREDENTIALS = API( + CREDENTIALS_API, HTTPMethod.GET, HTTPStatus.OK, endpoint=EndPoint.HERACLES +) UPDATE_CREDENTIAL_BY_GUID = API( CREDENTIALS_API + "/{credential_guid}", HTTPMethod.POST, diff --git a/pyatlan/client/credential.py b/pyatlan/client/credential.py index 6b81fe0e7..ca9e7e464 100644 --- a/pyatlan/client/credential.py +++ b/pyatlan/client/credential.py @@ -1,7 +1,11 @@ +from json import dumps +from typing import Any, Dict, Optional + from pydantic.v1 import validate_arguments from pyatlan.client.common import ApiCaller from pyatlan.client.constants import ( + GET_ALL_CREDENTIALS, GET_CREDENTIAL_BY_GUID, TEST_CREDENTIAL, UPDATE_CREDENTIAL_BY_GUID, @@ -10,6 +14,7 @@ from pyatlan.model.credential import ( Credential, CredentialResponse, + CredentialResponseList, CredentialTestResponse, ) @@ -47,6 +52,43 @@ def get(self, guid: str) -> CredentialResponse: return raw_json return CredentialResponse(**raw_json) + @validate_arguments + def get_all( + self, + filter: Optional[Dict[str, Any]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> CredentialResponseList: + """ + Retrieves all credentials. + + :param filter: (optional) dictionary specifying the filter criteria. + :param limit: (optional) maximum number of credentials to retrieve. + :param offset: (optional) number of credentials to skip before starting retrieval. + :returns: CredentialResponseList instance. + :raises: AtlanError on any error during API invocation. + """ + params: Dict[str, Any] = {} + if filter is not None: + params["filter"] = dumps(filter) + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + + raw_json = self._client._call_api( + GET_ALL_CREDENTIALS.format_path_with_params(), query_params=params + ) + + if not isinstance(raw_json, dict) or "records" not in raw_json: + raise ErrorCode.JSON_ERROR.exception_with_parameters( + "No records found in response", + 400, + "API response did not contain the expected 'records' key", + ) + + return CredentialResponseList(**raw_json) + @validate_arguments def test(self, credential: Credential) -> CredentialTestResponse: """ diff --git a/pyatlan/model/credential.py b/pyatlan/model/credential.py index cb677d8e3..0e329c136 100644 --- a/pyatlan/model/credential.py +++ b/pyatlan/model/credential.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from pydantic.v1 import Field @@ -54,20 +54,20 @@ class Credential(AtlanObject): class CredentialResponse(AtlanObject): - id: str - version: str - is_active: bool - created_at: int - updated_at: int - created_by: str - tenant_id: str - name: str + id: Optional[str] + version: Optional[str] + is_active: Optional[bool] + created_at: Optional[int] + updated_at: Optional[int] + created_by: Optional[str] + tenant_id: Optional[str] + name: Optional[str] description: Optional[str] - connector_config_name: str - connector: str - connector_type: str - auth_type: str - host: str + connector_config_name: Optional[str] + connector: Optional[str] + connector_type: Optional[str] + auth_type: Optional[str] + host: Optional[str] port: Optional[int] metadata: Optional[Dict[str, Any]] level: Optional[Dict[str, Any]] @@ -94,6 +94,16 @@ def to_credential(self) -> Credential: ) +class CredentialResponseList(AtlanObject): + """ + Model representing a response containing a list of CredentialResponse objects. + """ + + records: Optional[List[CredentialResponse]] = Field( + default=None, description="list of credential records returned." + ) + + class CredentialTestResponse(AtlanObject): code: Optional[int] error: Optional[str] diff --git a/tests/integration/test_workflow_client.py b/tests/integration/test_workflow_client.py index bb09e098a..61f6e5e6b 100644 --- a/tests/integration/test_workflow_client.py +++ b/tests/integration/test_workflow_client.py @@ -269,3 +269,72 @@ def test_workflow_add_remove_schedule(client: AtlanClient, workflow: WorkflowRes # Now remove the scheduled run response = client.workflow.remove_schedule(workflow) _assert_remove_schedule(response, workflow) + + +def test_get_all_credentials(client: AtlanClient): + credentials = client.credentials.get_all() + assert credentials, "Expected credentials but found None" + assert credentials.records is not None, "Expected records but found None" + assert ( + len(credentials.records or []) > 0 + ), "Expected at least one record but found none" + + +def test_get_all_credentials_with_limit_and_offset(client: AtlanClient): + limit = 5 + offset = 2 + credentials = client.credentials.get_all(limit=limit, offset=offset) + assert credentials.records is not None, "Expected records but found None" + assert ( + len(credentials.records or []) <= limit + ), f"Expected at most {limit} records, got {len(credentials.records or [])}" + + +def test_get_all_credentials_with_filter_limit_offset(client: AtlanClient): + filter_criteria = {"connectorType": "jdbc"} + limit = 1 + offset = 1 + credentials = client.credentials.get_all( + filter=filter_criteria, limit=limit, offset=offset + ) + assert len(credentials.records or []) <= limit, "Exceeded limit in results" + for cred in credentials.records or []: + assert ( + cred.connector_type == "jdbc" + ), f"Expected 'jdbc', got {cred.connector_type}" + + +def test_get_all_credentials_with_multiple_filters(client: AtlanClient): + filter_criteria = {"connectorType": "jdbc", "isActive": True} + + credentials = client.credentials.get_all(filter=filter_criteria) + assert credentials, "Expected credentials but found None" + assert credentials.records is not None, "Expected records but found None" + assert ( + len(credentials.records or []) > 0 + ), "Expected at least one record but found none" + + for record in credentials.records or []: + assert ( + record.connector_type == "jdbc" + ), f"Expected 'jdbc', got {record.connector_type}" + assert record.is_active, f"Expected active record, but got inactive: {record}" + + +def test_get_all_credentials_with_invalid_filter_key(client: AtlanClient): + filter_criteria = {"invalidKey": "someValue"} + try: + client.credentials.get_all(filter=filter_criteria) + pytest.fail("Expected an error due to invalid filter key, but none occurred.") + except Exception as e: + assert "400" in str(e), f"Expected a 400 error, but got: {e}" + + +def test_get_all_credentials_with_invalid_filter_value(client: AtlanClient): + filter_criteria = {"connector_type": 123} + + try: + client.credentials.get_all(filter=filter_criteria) + pytest.fail("Expected an error due to invalid filter value, but none occurred.") + except Exception as e: + assert "400" in str(e), f"Expected a 400 error, but got: {e}" diff --git a/tests/unit/test_credential_client.py b/tests/unit/test_credential_client.py index 2cfacf59e..00961cf16 100644 --- a/tests/unit/test_credential_client.py +++ b/tests/unit/test_credential_client.py @@ -11,6 +11,7 @@ from pyatlan.model.credential import ( Credential, CredentialResponse, + CredentialResponseList, CredentialTestResponse, ) @@ -189,3 +190,97 @@ def test_cred_test_update_when_given_cred( assert isinstance(cred_response, CredentialResponse) cred = cred_response.to_credential() _assert_cred_response(cred, credential_response) + + +@pytest.mark.parametrize( + "test_filter, test_limit, test_offset, test_response", + [ + (None, None, None, {"records": [{"id": "cred1"}, {"id": "cred2"}]}), + ({"name": "test"}, 5, 0, {"records": [{"id": "cred3"}]}), + ({"invalid": "field"}, 10, 0, {"records": []}), + ], +) +def test_cred_get_all_success( + test_filter, test_limit, test_offset, test_response, mock_api_caller +): + mock_api_caller._call_api.return_value = test_response + client = CredentialClient(mock_api_caller) + + result = client.get_all(filter=test_filter, limit=test_limit, offset=test_offset) + + assert isinstance(result, CredentialResponseList) + assert len(result.records) == len(test_response["records"]) + for record, expected in zip(result.records, test_response["records"]): + assert record.id == expected["id"] + + +def test_cred_get_all_empty_response(mock_api_caller): + mock_api_caller._call_api.return_value = {"records": []} + client = CredentialClient(mock_api_caller) + + result = client.get_all() + + assert isinstance(result, CredentialResponseList) + assert len(result.records) == 0 + + +def test_cred_get_all_invalid_response(mock_api_caller): + mock_api_caller._call_api.return_value = {} + client = CredentialClient(mock_api_caller) + + with pytest.raises(Exception, match="No records found in response"): + client.get_all() + + +@pytest.mark.parametrize( + "test_filter, test_limit, test_offset", + [ + ("invalid_filter", None, None), + (None, "invalid_limit", None), + (None, None, "invalid_offset"), + ], +) +def test_cred_get_all_invalid_params_raises_validation_error( + test_filter, test_limit, test_offset, client: CredentialClient +): + with pytest.raises(ValidationError): + client.get_all(filter=test_filter, limit=test_limit, offset=test_offset) + + +def test_cred_get_all_timeout(mock_api_caller): + mock_api_caller._call_api.side_effect = TimeoutError("Request timed out") + client = CredentialClient(mock_api_caller) + + with pytest.raises(TimeoutError, match="Request timed out"): + client.get_all() + + +def test_cred_get_all_partial_response(mock_api_caller): + mock_api_caller._call_api.return_value = { + "records": [{"id": "cred1", "name": "Test Credential"}] + } + client = CredentialClient(mock_api_caller) + + result = client.get_all() + + assert isinstance(result, CredentialResponseList) + assert result.records[0].id == "cred1" + assert result.records[0].name == "Test Credential" + assert result.records[0].host is None + + +def test_cred_get_all_invalid_filter_type(mock_api_caller): + client = CredentialClient(mock_api_caller) + + with pytest.raises(ValidationError, match="value is not a valid dict"): + client.get_all(filter="invalid_filter") + + +def test_cred_get_all_no_results(mock_api_caller): + mock_api_caller._call_api.return_value = {"records": []} + client = CredentialClient(mock_api_caller) + + result = client.get_all(filter={"name": "nonexistent"}) + + assert isinstance(result, CredentialResponseList) + assert len(result.records) == 0