Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FT-778: Add credential list endpoint to the SDKs #427

Merged
merged 15 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyatlan/client/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions pyatlan/client/credential.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +14,7 @@
from pyatlan.model.credential import (
Credential,
CredentialResponse,
CredentialResponseList,
CredentialTestResponse,
)

Expand Down Expand Up @@ -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:
"""
Expand Down
38 changes: 24 additions & 14 deletions pyatlan/model/credential.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional

from pydantic.v1 import Field

Expand Down Expand Up @@ -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]]
Expand All @@ -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]
Expand Down
69 changes: 69 additions & 0 deletions tests/integration/test_workflow_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
95 changes: 95 additions & 0 deletions tests/unit/test_credential_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pyatlan.model.credential import (
Credential,
CredentialResponse,
CredentialResponseList,
CredentialTestResponse,
)

Expand Down Expand Up @@ -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
Loading