Skip to content
Open
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
84 changes: 82 additions & 2 deletions firebase_admin/dataconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@
from dataclasses import dataclass, asdict, is_dataclass
import typing
from typing import Any, Dict, Generic, Optional, Type, TypeVar, Union


import requests

import firebase_admin
from firebase_admin import _utils, _http_client, App

from firebase_admin import _utils, _http_client, App, exceptions

__all__ = [
'ConnectorConfig',
Expand All @@ -32,6 +37,7 @@
'GraphqlOptions',
'Impersonation',
'ExecuteGraphqlResponse',
'QueryError',
]

_DATA_CONNECT_ATTRIBUTE = '_data_connect'
Expand All @@ -52,6 +58,21 @@
_Data = TypeVar("_Data")
_Variables = TypeVar("_Variables")


# Error Codes
_QUERY_ERROR_CODE = 'query-error'


class QueryError(exceptions.FirebaseError):
"""Raised when a GraphQL query or mutation execution fails."""

def __init__(self, message: str, http_response: Any = None) -> None:
super().__init__(
code=_QUERY_ERROR_CODE,
message=message,
http_response=http_response
)

@dataclass(frozen=True)
class ConnectorConfig:
"""A configuration object for DataConnect.
Expand Down Expand Up @@ -96,6 +117,7 @@ def __init__(self, app: App, config: ConnectorConfig) -> None:
"""Initializes a DataConnect client instance. """
self._app: App = app
self._config = config
self._client = _DataConnectApiClient(connector_config=config, app=app)

@property
def app(self) -> App:
Expand Down Expand Up @@ -149,7 +171,6 @@ def client(config: ConnectorConfig, app: Optional[App] = None) -> DataConnect:
return dc_service.get_client(config)



class Impersonation(dict):
"""Represents impersonation configuration for DataConnect requests."""

Expand Down Expand Up @@ -334,3 +355,62 @@ def _get_headers(self) -> Dict[str, str]:
"X-Firebase-Client": f"fire-admin-python/{firebase_admin.__version__}",
"x-goog-api-client": _utils.get_metrics_header(),
}

@staticmethod
def _check_graphql_errors(resp_dict: Any, resp: Any) -> None:
"""Raises QueryError if the GraphQL response payload contains an errors key."""
if isinstance(resp_dict, dict) and "errors" in resp_dict:
errors = resp_dict["errors"]
all_messages = ""
if isinstance(errors, list):
messages = []
for err in errors:
if isinstance(err, dict):
message = err.get("message")
if message:
messages.append(message)
all_messages = " ".join(messages)
if not all_messages:
all_messages = (
f"GraphQL execution failed: {errors}" if errors
else "GraphQL execution failed."
)
raise QueryError(
message=all_messages,
http_response=resp
)

def _make_gql_request(
self,
url: str,
headers: Dict[str, str],
payload: Dict[str, Any]
) -> Dict[str, Any]:
"""Make a GraphQL request to the Data Connect service."""
if url is None or headers is None or payload is None:
raise ValueError("url, headers, and payload must all be specified.")

try:
resp_dict, resp = self._http_client.body_and_response(
'post',
url=url,
headers=headers,
json=payload
)
except requests.exceptions.RequestException as error:
raise _utils.handle_platform_error_from_requests(error)

_DataConnectApiClient._check_graphql_errors(resp_dict, resp)
return resp_dict

@staticmethod
def _parse_graphql_response(
resp_dict: Dict[str, Any]
) -> ExecuteGraphqlResponse[Any]:
"""Parses a raw GraphQL response payload into ExecuteGraphqlResponse."""
if not isinstance(resp_dict, dict):
raise exceptions.InternalError(
message="Response payload is not a valid JSON dictionary."
)

return ExecuteGraphqlResponse(data=resp_dict.get("data"))
179 changes: 168 additions & 11 deletions tests/test_data_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
from typing import Any, Dict, Mapping
from unittest import mock


from google.auth import credentials as google_auth_credentials
import pytest

import requests

import firebase_admin
from firebase_admin import _utils
from firebase_admin import _utils, _http_client, exceptions
from firebase_admin import dataconnect
from tests import testutils

Expand Down Expand Up @@ -100,7 +101,9 @@ def teardown_method(self, method):
def test_init_property_assignment(self):
cred = testutils.MockCredential()
try:
app = firebase_admin.initialize_app(cred, name="starter_app")
app = firebase_admin.initialize_app(
cred, options={'projectId': 'test-project'}, name="starter_app"
)
except ValueError:
pytest.fail("initialize app has an error")

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

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


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

def setup_method(self):
self.cred = testutils.MockCredential()
self.app = firebase_admin.initialize_app(self.cred, name="starter_app")
self.app = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}, name="starter_app"
)
self.config1 = BASE_CONFIG
self.config2 = dataconnect.ConnectorConfig(
service_id="starterproject2", location="us-east4", connector="my_connector2"
Expand All @@ -148,7 +152,9 @@ def test_client_successful(self, mock_get_client):
assert client2.config is self.config2

def test_client_retrieval_different_apps_same_config(self):
app2 = firebase_admin.initialize_app(self.cred, name="app2")
app2 = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}, name="app2"
)

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

def test_client_default_app(self):
default_app = firebase_admin.initialize_app(self.cred)
default_app = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}
)
client_instance = dataconnect.client(self.config1)
assert client_instance.app is default_app

Expand All @@ -191,9 +199,12 @@ class TestDataConnectService:

def setup_method(self):
self.cred = testutils.MockCredential()
self.app = firebase_admin.initialize_app(self.cred, name="starter_app")
self.app = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}, name="starter_app"
)
self.service = dataconnect._DataConnectService(self.app) # pylint: disable=protected-access


def teardown_method(self, method):
del method
testutils.cleanup_apps()
Expand Down Expand Up @@ -297,8 +308,12 @@ class TestDataConnectServiceWorkflow:

def setup_method(self):
self.cred = testutils.MockCredential()
self.app1 = firebase_admin.initialize_app(self.cred, name="integ_app1")
self.app2 = firebase_admin.initialize_app(self.cred, name="integ_app2")
self.app1 = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}, name="integ_app1"
)
self.app2 = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}, name="integ_app2"
)

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


def teardown_method(self, method):
del method
testutils.cleanup_apps()
Expand Down Expand Up @@ -724,3 +740,144 @@ def test_get_headers(self):
assert isinstance(headers, dict)
assert headers.get("X-Firebase-Client") == f"fire-admin-python/{firebase_admin.__version__}"
assert headers.get("x-goog-api-client") == _utils.get_metrics_header()


class TestDataConnectApiClientMakeGqlRequest:

def setup_method(self):
self.cred = testutils.MockCredential()
self.app = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}
)
self.api_client = dataconnect._DataConnectApiClient(BASE_CONFIG, self.app)

def teardown_method(self, method):
del method
testutils.cleanup_apps()

@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
def test_make_gql_request_success(self, mock_body_and_response):
mock_response = mock.Mock(spec=requests.Response)
mock_body_and_response.return_value = ({"data": "val"}, mock_response)
url = "https://example.com/endpoint"
headers = {"key": "val"}
payload = {"query": "foo"}

res = self.api_client._make_gql_request(url, headers, payload)
assert res == {"data": "val"}
mock_body_and_response.assert_called_once_with(
"post", url=url, headers=headers, json=payload
)

def test_make_gql_request_missing_url(self):
headers = {"key": "val"}
payload = {"query": "foo"}
with pytest.raises(ValueError, match="url, headers, and payload must all be specified."):
self.api_client._make_gql_request(None, headers, payload)

def test_make_gql_request_missing_headers(self):
url = "https://example.com/endpoint"
payload = {"query": "foo"}
with pytest.raises(ValueError, match="url, headers, and payload must all be specified."):
self.api_client._make_gql_request(url, None, payload)

def test_make_gql_request_missing_payload(self):
url = "https://example.com/endpoint"
headers = {"key": "val"}
with pytest.raises(ValueError, match="url, headers, and payload must all be specified."):
self.api_client._make_gql_request(url, headers, None)

@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
def test_make_gql_request_error(self, mock_body_and_response):
mock_body_and_response.side_effect = requests.exceptions.RequestException()
url = "https://example.com/endpoint"
headers = {"key": "val"}
payload = {"query": "foo"}

with pytest.raises(exceptions.FirebaseError):
self.api_client._make_gql_request(url, headers, payload)
Comment thread
mk2023 marked this conversation as resolved.

@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
def test_make_gql_request_server_errors(self, mock_body_and_response):
mock_response = mock.Mock(spec=requests.Response)
mock_body_and_response.return_value = (
{
"errors": [
{"message": "First error."},
{"message": "Second error."}
]
},
mock_response
)
url = "https://example.com/endpoint"
headers = {"key": "val"}
payload = {"query": "foo"}

with pytest.raises(exceptions.FirebaseError) as excinfo:
self.api_client._make_gql_request(url, headers, payload)

assert excinfo.value.code == "query-error"
assert str(excinfo.value) == "First error. Second error."
assert excinfo.value.http_response is mock_response

@mock.patch.object(_http_client.JsonHttpClient, "body_and_response")
def test_make_gql_request_non_standard_errors(self, mock_body_and_response):
mock_response = mock.Mock(spec=requests.Response)
mock_body_and_response.return_value = (
{"errors": "String error message"},
mock_response
)
url = "https://example.com/endpoint"
headers = {"key": "val"}
payload = {"query": "foo"}

with pytest.raises(exceptions.FirebaseError) as excinfo:
self.api_client._make_gql_request(url, headers, payload)

assert excinfo.value.code == "query-error"
assert str(excinfo.value) == "GraphQL execution failed: String error message"

mock_body_and_response.return_value = (
{"errors": []},
mock_response
)
with pytest.raises(exceptions.FirebaseError) as excinfo:
self.api_client._make_gql_request(url, headers, payload)

assert str(excinfo.value) == "GraphQL execution failed."


class TestParseGraphqlResponse:

def setup_method(self):
self.cred = testutils.MockCredential()
self.app = firebase_admin.initialize_app(
self.cred, options={'projectId': 'test-project'}
)
self.api_client = dataconnect._DataConnectApiClient(BASE_CONFIG, self.app)

def teardown_method(self, method):
del method
testutils.cleanup_apps()

def test_parse_graphql_response_to_dictionary(self):
payload = {
"data": {"name": "Fred", "age": 20}
}
res = self.api_client._parse_graphql_response(payload)
assert isinstance(res, dataconnect.ExecuteGraphqlResponse)
assert res.data == {"name": "Fred", "age": 20}


def test_parse_graphql_response_none_data(self):
payload = {"data": None}
res = self.api_client._parse_graphql_response(payload)
assert isinstance(res, dataconnect.ExecuteGraphqlResponse)
assert res.data is None

def test_parse_graphql_response_non_dict_error(self):
with pytest.raises(exceptions.InternalError) as excinfo:
self.api_client._parse_graphql_response("not-a-dict")

assert excinfo.value.code == exceptions.INTERNAL
assert str(excinfo.value) == "Response payload is not a valid JSON dictionary."