Skip to content
44 changes: 34 additions & 10 deletions cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This API provides a wrapper around the legacy DML API for managing GraphQL data models.
"""

import json
from collections.abc import Iterable, Sequence
from typing import Any

Expand All @@ -26,13 +27,22 @@
from cognite_toolkit._cdf_tk.utils import humanize_collection


class DMLError(BaseModel):
model_config = ConfigDict(extra="allow")
kind: str | None = None
message: str | None = None
hint: str | None = None


class UpsertResponseData(BaseModel):
errors: dict[str, Any] | None = None
result: GraphQLDataModelResponse
errors: list[DMLError] | None = None
result: GraphQLDataModelResponse | None = None


class GraphQLUpsertResponse(BaseModel):
upsert_graph_ql_dml_version: UpsertResponseData = Field(alias="upsertGraphQlDmlVersion")
# Nullable: the API sets this to null and populates top-level errors when
# the mutation input is rejected (e.g. unknown fields, auth failures).
upsert_graph_ql_dml_version: UpsertResponseData | None = Field(None, alias="upsertGraphQlDmlVersion")


class GraphQLErrors(BaseModel):
Expand All @@ -43,7 +53,7 @@ class GraphQLErrors(BaseModel):


class GraphQLResponse(BaseModel):
data: GraphQLUpsertResponse
data: GraphQLUpsertResponse | None = None
errors: list[GraphQLErrors] | None = None


Expand Down Expand Up @@ -79,11 +89,21 @@ def _post_graphql(self, payload: dict[str, Any]) -> GraphQLUpsertResponse:
)
result = self._http_client.request_single_retries(request)
response = result.get_success_or_raise(request)
parsed = GraphQLResponse.model_validate_json(response.body)
if errors := parsed.errors:
raise ToolkitAPIError(
f"Failed GraphQL errors: {humanize_collection([error.message for error in errors if error.message])}"
)
# Parse as raw dict first so top-level GraphQL errors (which accompany a
# null upsertGraphQlDmlVersion) are surfaced before Pydantic validation.
raw = json.loads(response.body)
if top_errors := raw.get("errors"):
messages = [e.get("message", str(e)) for e in top_errors if isinstance(e, dict)]
raise ToolkitAPIError(f"GraphQL mutation failed: {humanize_collection(messages)}")
Comment on lines +94 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the response body is not a JSON object (e.g., if it is a JSON array, a primitive, or if an intermediate proxy returns an unexpected format), raw will not be a dictionary and calling raw.get will raise an AttributeError. Additionally, if top_errors is not a list or contains non-dictionary elements, the current list comprehension will either ignore them or fail. Checking that raw is a dictionary and handling non-list/non-dict errors gracefully ensures robust error surfacing.

Suggested change
raw = json.loads(response.body)
if top_errors := raw.get("errors"):
messages = [e.get("message", str(e)) for e in top_errors if isinstance(e, dict)]
raise ToolkitAPIError(f"GraphQL mutation failed: {humanize_collection(messages)}")
raw = json.loads(response.body)
if isinstance(raw, dict) and (top_errors := raw.get("errors")):
if isinstance(top_errors, list):
messages = [e.get("message", str(e)) if isinstance(e, dict) else str(e) for e in top_errors]
else:
messages = [str(top_errors)]
raise ToolkitAPIError(f"GraphQL mutation failed: {humanize_collection(messages)}")
References
  1. Error Handling: Graceful handling: Provide meaningful error messages. (link)

parsed = GraphQLResponse.model_validate(raw)
if parsed.data is None:
raise ToolkitAPIError("GraphQL mutation returned no data and no errors.")
upsert = parsed.data.upsert_graph_ql_dml_version
if upsert is None:
raise ToolkitAPIError("GraphQL mutation returned no result and no errors.")
if upsert.errors:
messages = [e.message for e in upsert.errors if e.message]
raise ToolkitAPIError(f"DML validation failed: {humanize_collection(messages)}")
Comment on lines +104 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If upsert.errors is populated but none of the errors contain a message (e.g., they only have kind and hint), the list comprehension will produce an empty list, resulting in an unhelpful empty error message (DML validation failed: ). We should fall back to formatting the kind and hint fields if message is absent.

Suggested change
if upsert.errors:
messages = [e.message for e in upsert.errors if e.message]
raise ToolkitAPIError(f"DML validation failed: {humanize_collection(messages)}")
if upsert.errors:
messages = [
e.message or f"{e.kind or 'Error'}(hint={e.hint})"
for e in upsert.errors
]
raise ToolkitAPIError(f"DML validation failed: {humanize_collection(messages)}")
References
  1. Error Handling: Graceful handling: Provide meaningful error messages. (link)

return parsed.data

def create(self, items: Sequence[GraphQLDataModelRequest]) -> list[GraphQLDataModelResponse]:
Expand All @@ -102,7 +122,11 @@ def create(self, items: Sequence[GraphQLDataModelRequest]) -> list[GraphQLDataMo
"variables": {"dmCreate": item.model_dump(mode="json", by_alias=True, exclude_unset=False)},
}
response = self._post_graphql(payload)
results.append(response.upsert_graph_ql_dml_version.result)
# _post_graphql raises before returning if upsert_graph_ql_dml_version or result is None.
upsert = response.upsert_graph_ql_dml_version
if upsert is None or upsert.result is None:
raise ToolkitAPIError("GraphQL mutation succeeded but returned no data model.")
results.append(upsert.result)
return results

def retrieve(self, items: Sequence[DataModelId], inline_views: bool = False) -> list[GraphQLDataModelResponse]:
Expand Down
59 changes: 59 additions & 0 deletions tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,3 +671,62 @@ def test_dml_updated_to_renamed_graphql_in_build(self, tmp_path: Path) -> None:
assert entry["dml"] == renamed_graphql, (
f"entry['dml'] was not updated after build rename: got {entry['dml']!r}, expected {renamed_graphql!r}"
)


class TestGraphQLDataModelsAPI:
"""Regression tests for GraphQLDataModelsAPI error surfacing."""

@staticmethod
def _make_api(response_body: str): # type: ignore[return]
from unittest.mock import MagicMock

from cognite_toolkit._cdf_tk.client.api.graphql_data_models import GraphQLDataModelsAPI

mock_success = MagicMock()
mock_success.body = response_body
mock_result = MagicMock()
mock_result.get_success_or_raise.return_value = mock_success
mock_http = MagicMock()
mock_http.request_single_retries.return_value = mock_result

api = GraphQLDataModelsAPI(http_client=mock_http)
api._make_url = MagicMock(return_value="https://api.cognitedata.com/dml/graphql") # type: ignore[method-assign]
return api

def test_top_level_graphql_error_surfaced_not_swallowed(self) -> None:
# Regression: when the API returns {"data": {"upsertGraphQlDmlVersion": null}, "errors": [...]}
# the client was crashing with a cryptic Pydantic validation error instead of the real message.
import json

from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError

api_response = json.dumps(
{
"data": {"upsertGraphQlDmlVersion": None},
"errors": [{"message": "Unknown argument 'dml' on field 'upsertGraphQlDmlVersion'"}],
}
)
api = self._make_api(api_response)

with pytest.raises(ToolkitAPIError, match="Unknown argument 'dml'"):
api._post_graphql({"query": "...", "variables": {}})

def test_dml_validation_errors_surfaced(self) -> None:
import json

from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError

api_response = json.dumps(
{
"data": {
"upsertGraphQlDmlVersion": {
"errors": [{"kind": "SYNTAX", "message": "Invalid type 'Foo'", "hint": None}],
"result": None,
}
},
}
)
api = self._make_api(api_response)

with pytest.raises(ToolkitAPIError, match="Invalid type 'Foo'"):
api._post_graphql({"query": "...", "variables": {}})
Loading