Skip to content

Commit 2eb6bd8

Browse files
authored
feat(fdc): Add request execution and response parsing to _DataConnectApiClient (#969)
* feat(fdc): Add internal GraphQL request helper method and tests Implemented _make_gql_request on _DataConnectApiClient to execute and handle responses/errors for GraphQL operations. Added corresponding unit tests in tests/test_data_connect.py. * feat(fdc): Add GraphQL response parsing and deserialization logic Refactored _parse_graphql_response and added robust recursive type deserialization to _DataConnectApiClient. - Implemented _deserialize_type and _deserialize_dataclass helper methods to support nested dataclasses, generic lists (List[T]), generic dictionaries (Dict[K, V]), Unions (Union[...]), Enums, and primitive casting. - Enhanced _make_gql_request error handling to prevent silent error swallowing when the errors key is present. - Added comprehensive unit test coverage in tests/test_data_connect.py. * fix(fdc): Add custom QueryError exception and GraphQL error checking helper - Introduced QueryError subclass of FirebaseError for Data Connect GraphQL query/mutation errors and exposed it in __all__. - Extracted _check_graphql_errors helper method on _DataConnectApiClient. - Updated error handling for non-dictionary response payloads in _parse_graphql_response to raise InternalError. - Note: Did not edit parse_graphql_response because we are waiting on whether this will even be a function or not. * refactor(fdc): Remove response deserialization and use immediate client instantiation - Removed output deserialization helpers (_extract_actual_type, _deserialize_type, _deserialize_dataclass) to return raw JSON payload dictionaries (ExecuteGraphqlResponse.data), aligning Data Connect with Firestore and Realtime Database patterns for user-defined schemas. - Updated DataConnect.__init__ to immediately instantiate _DataConnectApiClient for consistency with Node.js and other Python Admin SDK services. - Updated test suite in tests/test_data_connect.py to cover raw response parsing and immediate client instantiation. * feat(fdc): Add TODO for partial errors and include response payload in error message Added a TODO comment referencing b/406281627 for partial errors support in ExecuteGraphqlResponse and _parse_graphql_response. Updated _parse_graphql_response to include the raw response payload in the InternalError message string for improved debug visibility.
1 parent 099697a commit 2eb6bd8

2 files changed

Lines changed: 259 additions & 13 deletions

File tree

firebase_admin/dataconnect.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@
2222
from dataclasses import dataclass, asdict, is_dataclass
2323
import typing
2424
from typing import Any, Dict, Generic, Optional, Type, TypeVar, Union
25+
26+
import requests
27+
2528
import firebase_admin
26-
from firebase_admin import _utils, _http_client, App
29+
30+
from firebase_admin import _utils, _http_client, App, exceptions
2731

2832
__all__ = [
2933
'ConnectorConfig',
@@ -32,6 +36,7 @@
3236
'GraphqlOptions',
3337
'Impersonation',
3438
'ExecuteGraphqlResponse',
39+
'QueryError',
3540
]
3641

3742
_DATA_CONNECT_ATTRIBUTE = '_data_connect'
@@ -52,6 +57,21 @@
5257
_Data = TypeVar("_Data")
5358
_Variables = TypeVar("_Variables")
5459

60+
61+
# Error Codes
62+
_QUERY_ERROR_CODE = 'query-error'
63+
64+
65+
class QueryError(exceptions.FirebaseError):
66+
"""Raised when a GraphQL query or mutation execution fails."""
67+
68+
def __init__(self, message: str, http_response: Any = None) -> None:
69+
super().__init__(
70+
code=_QUERY_ERROR_CODE,
71+
message=message,
72+
http_response=http_response
73+
)
74+
5575
@dataclass(frozen=True)
5676
class ConnectorConfig:
5777
"""A configuration object for DataConnect.
@@ -96,6 +116,7 @@ def __init__(self, app: App, config: ConnectorConfig) -> None:
96116
"""Initializes a DataConnect client instance. """
97117
self._app: App = app
98118
self._config = config
119+
self._client = _DataConnectApiClient(connector_config=config, app=app)
99120

100121
@property
101122
def app(self) -> App:
@@ -149,7 +170,6 @@ def client(config: ConnectorConfig, app: Optional[App] = None) -> DataConnect:
149170
return dc_service.get_client(config)
150171

151172

152-
153173
class Impersonation(dict):
154174
"""Represents impersonation configuration for DataConnect requests."""
155175

@@ -174,11 +194,18 @@ class GraphqlOptions(Generic[_Variables]):
174194
impersonate: Optional[Union[Impersonation, Dict[str, Any]]] = None
175195

176196

197+
# TODO(b/406281627): Add support for partial errors.
177198
@dataclass
178199
class ExecuteGraphqlResponse(Generic[_Data]):
200+
"""Represents the response from a DataConnect GraphQL execution.
201+
202+
Attributes:
203+
data: The raw JSON dictionary returned by the GraphQL execution.
204+
"""
179205
data: _Data
180206

181207

208+
182209
def _get_emulator_host() -> Optional[str]:
183210
return _utils.get_emulator_host("DATA_CONNECT_EMULATOR_HOST")
184211

@@ -334,3 +361,63 @@ def _get_headers(self) -> Dict[str, str]:
334361
"X-Firebase-Client": f"fire-admin-python/{firebase_admin.__version__}",
335362
"x-goog-api-client": _utils.get_metrics_header(),
336363
}
364+
365+
@staticmethod
366+
def _check_graphql_errors(resp_dict: Any, resp: Any) -> None:
367+
"""Raises QueryError if the GraphQL response payload contains an errors key."""
368+
if isinstance(resp_dict, dict) and "errors" in resp_dict:
369+
errors = resp_dict["errors"]
370+
all_messages = ""
371+
if isinstance(errors, list):
372+
messages = []
373+
for err in errors:
374+
if isinstance(err, dict):
375+
message = err.get("message")
376+
if message:
377+
messages.append(message)
378+
all_messages = " ".join(messages)
379+
if not all_messages:
380+
all_messages = (
381+
f"GraphQL execution failed: {errors}" if errors
382+
else "GraphQL execution failed."
383+
)
384+
raise QueryError(
385+
message=all_messages,
386+
http_response=resp
387+
)
388+
389+
def _make_gql_request(
390+
self,
391+
url: str,
392+
headers: Dict[str, str],
393+
payload: Dict[str, Any]
394+
) -> Dict[str, Any]:
395+
"""Make a GraphQL request to the Data Connect service."""
396+
if url is None or headers is None or payload is None:
397+
raise ValueError("url, headers, and payload must all be specified.")
398+
399+
try:
400+
resp_dict, resp = self._http_client.body_and_response(
401+
'post',
402+
url=url,
403+
headers=headers,
404+
json=payload
405+
)
406+
except requests.exceptions.RequestException as error:
407+
raise _utils.handle_platform_error_from_requests(error)
408+
409+
_DataConnectApiClient._check_graphql_errors(resp_dict, resp)
410+
return resp_dict
411+
412+
@staticmethod
413+
def _parse_graphql_response(
414+
resp_dict: Dict[str, Any]
415+
) -> ExecuteGraphqlResponse[Any]:
416+
"""Parses a raw GraphQL response payload into ExecuteGraphqlResponse."""
417+
if not isinstance(resp_dict, dict):
418+
raise exceptions.InternalError(
419+
message=f"Response payload is not a valid JSON dictionary: {resp_dict}"
420+
)
421+
422+
# TODO(b/406281627): Add support for partial errors.
423+
return ExecuteGraphqlResponse(data=resp_dict.get("data"))

tests/test_data_connect.py

Lines changed: 170 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
from typing import Any, Dict, Mapping
1919
from unittest import mock
2020

21+
2122
from google.auth import credentials as google_auth_credentials
2223
import pytest
23-
24+
import requests
2425

2526
import firebase_admin
26-
from firebase_admin import _utils
27+
from firebase_admin import _utils, _http_client, exceptions
2728
from firebase_admin import dataconnect
2829
from tests import testutils
2930

@@ -100,7 +101,9 @@ def teardown_method(self, method):
100101
def test_init_property_assignment(self):
101102
cred = testutils.MockCredential()
102103
try:
103-
app = firebase_admin.initialize_app(cred, name="starter_app")
104+
app = firebase_admin.initialize_app(
105+
cred, options={'projectId': 'test-project'}, name="starter_app"
106+
)
104107
except ValueError:
105108
pytest.fail("initialize app has an error")
106109

@@ -113,9 +116,8 @@ def test_init_property_assignment(self):
113116
assert data_connect_instance._config is BASE_CONFIG # pylint: disable=protected-access
114117
assert data_connect_instance.app is app
115118
assert data_connect_instance.config is BASE_CONFIG
119+
assert isinstance(data_connect_instance._client, dataconnect._DataConnectApiClient) # pylint: disable=protected-access
116120

117-
assert data_connect_instance._app.name == "starter_app" # pylint: disable=protected-access
118-
assert data_connect_instance._config.service_id == "starterproject" # pylint: disable=protected-access
119121

120122

121123
class TestDataConnectClientFactory:
@@ -126,7 +128,9 @@ def teardown_method(self, method):
126128

127129
def setup_method(self):
128130
self.cred = testutils.MockCredential()
129-
self.app = firebase_admin.initialize_app(self.cred, name="starter_app")
131+
self.app = firebase_admin.initialize_app(
132+
self.cred, options={'projectId': 'test-project'}, name="starter_app"
133+
)
130134
self.config1 = BASE_CONFIG
131135
self.config2 = dataconnect.ConnectorConfig(
132136
service_id="starterproject2", location="us-east4", connector="my_connector2"
@@ -148,7 +152,9 @@ def test_client_successful(self, mock_get_client):
148152
assert client2.config is self.config2
149153

150154
def test_client_retrieval_different_apps_same_config(self):
151-
app2 = firebase_admin.initialize_app(self.cred, name="app2")
155+
app2 = firebase_admin.initialize_app(
156+
self.cred, options={'projectId': 'test-project'}, name="app2"
157+
)
152158

153159
client1 = dataconnect.client(self.config1, app=self.app)
154160
client2 = dataconnect.client(self.config1, app=app2)
@@ -167,7 +173,9 @@ def test_invalid_app_type(self):
167173
dataconnect.client(self.config1, "not-a-app")
168174

169175
def test_client_default_app(self):
170-
default_app = firebase_admin.initialize_app(self.cred)
176+
default_app = firebase_admin.initialize_app(
177+
self.cred, options={'projectId': 'test-project'}
178+
)
171179
client_instance = dataconnect.client(self.config1)
172180
assert client_instance.app is default_app
173181

@@ -191,9 +199,12 @@ class TestDataConnectService:
191199

192200
def setup_method(self):
193201
self.cred = testutils.MockCredential()
194-
self.app = firebase_admin.initialize_app(self.cred, name="starter_app")
202+
self.app = firebase_admin.initialize_app(
203+
self.cred, options={'projectId': 'test-project'}, name="starter_app"
204+
)
195205
self.service = dataconnect._DataConnectService(self.app) # pylint: disable=protected-access
196206

207+
197208
def teardown_method(self, method):
198209
del method
199210
testutils.cleanup_apps()
@@ -297,8 +308,12 @@ class TestDataConnectServiceWorkflow:
297308

298309
def setup_method(self):
299310
self.cred = testutils.MockCredential()
300-
self.app1 = firebase_admin.initialize_app(self.cred, name="integ_app1")
301-
self.app2 = firebase_admin.initialize_app(self.cred, name="integ_app2")
311+
self.app1 = firebase_admin.initialize_app(
312+
self.cred, options={'projectId': 'test-project'}, name="integ_app1"
313+
)
314+
self.app2 = firebase_admin.initialize_app(
315+
self.cred, options={'projectId': 'test-project'}, name="integ_app2"
316+
)
302317

303318
self.config1 = BASE_CONFIG
304319
self.config2 = dataconnect.ConnectorConfig(
@@ -308,6 +323,7 @@ def setup_method(self):
308323
service_id="starterproject", location="us-east4", connector="my_connector"
309324
)
310325

326+
311327
def teardown_method(self, method):
312328
del method
313329
testutils.cleanup_apps()
@@ -724,3 +740,146 @@ def test_get_headers(self):
724740
assert isinstance(headers, dict)
725741
assert headers.get("X-Firebase-Client") == f"fire-admin-python/{firebase_admin.__version__}"
726742
assert headers.get("x-goog-api-client") == _utils.get_metrics_header()
743+
744+
745+
class TestDataConnectApiClientMakeGqlRequest:
746+
747+
def setup_method(self):
748+
self.cred = testutils.MockCredential()
749+
self.app = firebase_admin.initialize_app(
750+
self.cred, options={'projectId': 'test-project'}
751+
)
752+
self.api_client = dataconnect._DataConnectApiClient(BASE_CONFIG, self.app)
753+
754+
def teardown_method(self, method):
755+
del method
756+
testutils.cleanup_apps()
757+
758+
@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
759+
def test_make_gql_request_success(self, mock_body_and_response):
760+
mock_response = mock.Mock(spec=requests.Response)
761+
mock_body_and_response.return_value = ({"data": "val"}, mock_response)
762+
url = "https://example.com/endpoint"
763+
headers = {"key": "val"}
764+
payload = {"query": "foo"}
765+
766+
res = self.api_client._make_gql_request(url, headers, payload)
767+
assert res == {"data": "val"}
768+
mock_body_and_response.assert_called_once_with(
769+
"post", url=url, headers=headers, json=payload
770+
)
771+
772+
def test_make_gql_request_missing_url(self):
773+
headers = {"key": "val"}
774+
payload = {"query": "foo"}
775+
with pytest.raises(ValueError, match="url, headers, and payload must all be specified."):
776+
self.api_client._make_gql_request(None, headers, payload)
777+
778+
def test_make_gql_request_missing_headers(self):
779+
url = "https://example.com/endpoint"
780+
payload = {"query": "foo"}
781+
with pytest.raises(ValueError, match="url, headers, and payload must all be specified."):
782+
self.api_client._make_gql_request(url, None, payload)
783+
784+
def test_make_gql_request_missing_payload(self):
785+
url = "https://example.com/endpoint"
786+
headers = {"key": "val"}
787+
with pytest.raises(ValueError, match="url, headers, and payload must all be specified."):
788+
self.api_client._make_gql_request(url, headers, None)
789+
790+
@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
791+
def test_make_gql_request_error(self, mock_body_and_response):
792+
mock_body_and_response.side_effect = requests.exceptions.RequestException()
793+
url = "https://example.com/endpoint"
794+
headers = {"key": "val"}
795+
payload = {"query": "foo"}
796+
797+
with pytest.raises(exceptions.FirebaseError):
798+
self.api_client._make_gql_request(url, headers, payload)
799+
800+
@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
801+
def test_make_gql_request_server_errors(self, mock_body_and_response):
802+
mock_response = mock.Mock(spec=requests.Response)
803+
mock_body_and_response.return_value = (
804+
{
805+
"errors": [
806+
{"message": "First error."},
807+
{"message": "Second error."}
808+
]
809+
},
810+
mock_response
811+
)
812+
url = "https://example.com/endpoint"
813+
headers = {"key": "val"}
814+
payload = {"query": "foo"}
815+
816+
with pytest.raises(exceptions.FirebaseError) as excinfo:
817+
self.api_client._make_gql_request(url, headers, payload)
818+
819+
assert excinfo.value.code == "query-error"
820+
assert str(excinfo.value) == "First error. Second error."
821+
assert excinfo.value.http_response is mock_response
822+
823+
@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
824+
def test_make_gql_request_non_standard_errors(self, mock_body_and_response):
825+
mock_response = mock.Mock(spec=requests.Response)
826+
mock_body_and_response.return_value = (
827+
{"errors": "String error message"},
828+
mock_response
829+
)
830+
url = "https://example.com/endpoint"
831+
headers = {"key": "val"}
832+
payload = {"query": "foo"}
833+
834+
with pytest.raises(exceptions.FirebaseError) as excinfo:
835+
self.api_client._make_gql_request(url, headers, payload)
836+
837+
assert excinfo.value.code == "query-error"
838+
assert str(excinfo.value) == "GraphQL execution failed: String error message"
839+
840+
mock_body_and_response.return_value = (
841+
{"errors": []},
842+
mock_response
843+
)
844+
with pytest.raises(exceptions.FirebaseError) as excinfo:
845+
self.api_client._make_gql_request(url, headers, payload)
846+
847+
assert str(excinfo.value) == "GraphQL execution failed."
848+
849+
850+
class TestParseGraphqlResponse:
851+
852+
def setup_method(self):
853+
self.cred = testutils.MockCredential()
854+
self.app = firebase_admin.initialize_app(
855+
self.cred, options={'projectId': 'test-project'}
856+
)
857+
self.api_client = dataconnect._DataConnectApiClient(BASE_CONFIG, self.app)
858+
859+
def teardown_method(self, method):
860+
del method
861+
testutils.cleanup_apps()
862+
863+
def test_parse_graphql_response_to_dictionary(self):
864+
payload = {
865+
"data": {"name": "Fred", "age": 20}
866+
}
867+
res = self.api_client._parse_graphql_response(payload)
868+
assert isinstance(res, dataconnect.ExecuteGraphqlResponse)
869+
assert res.data == {"name": "Fred", "age": 20}
870+
871+
872+
def test_parse_graphql_response_none_data(self):
873+
payload = {"data": None}
874+
res = self.api_client._parse_graphql_response(payload)
875+
assert isinstance(res, dataconnect.ExecuteGraphqlResponse)
876+
assert res.data is None
877+
878+
def test_parse_graphql_response_non_dict_error(self):
879+
with pytest.raises(exceptions.InternalError) as excinfo:
880+
self.api_client._parse_graphql_response("not-a-dict")
881+
882+
assert excinfo.value.code == exceptions.INTERNAL
883+
assert str(excinfo.value) == (
884+
"Response payload is not a valid JSON dictionary: not-a-dict"
885+
)

0 commit comments

Comments
 (0)