diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32ef50ae..cc628659 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,10 @@ jobs: run: firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' - name: Run Functions emulator tests run: firebase emulators:exec --config integration/emulators/firebase.json --only tasks,functions --project fake-project-id 'CLOUD_TASKS_EMULATOR_HOST=localhost:9499 pytest integration/test_functions.py' + - name: Run Data Connect emulator tests + run: firebase emulators:exec --config integration/emulators/firebase.json --only dataconnect --project fake-project-id './integration/emulators/seed.sh && DATA_CONNECT_EMULATOR_HOST=localhost:9399 pytest integration/test_data_connect.py --cert=tests/data/service_account.json' + + lint: runs-on: ubuntu-latest steps: diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index e9ef1aa2..502c9f9a 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -53,6 +53,9 @@ '/services/{service_id}:{endpoint_id}' ) +_EXECUTE_GRAPHQL_ENDPOINT = 'executeGraphql' +_EXECUTE_GRAPHQL_READ_ENDPOINT = 'executeGraphqlRead' + # Generic Type Parameters _Data = TypeVar("_Data") _Variables = TypeVar("_Variables") @@ -101,7 +104,70 @@ def __post_init__(self): raise ValueError("connector cannot be empty") +class Impersonation(dict): + """Represents impersonation configuration for DataConnect requests. + + It is recommended to construct instances using the static factory methods + :meth:`unauthenticated` or :meth:`authenticated`. + """ + + def __init__( + self, + *, + unauthenticated: Optional[bool] = None, + auth_claims: Optional[Dict[str, Any]] = None + ) -> None: + if unauthenticated is None and auth_claims is None: + raise ValueError( + "Impersonation requires either 'unauthenticated=True' or 'auth_claims'." + ) + if unauthenticated is not None and auth_claims is not None: + raise ValueError("Cannot specify both 'unauthenticated' and 'auth_claims'.") + + if unauthenticated is not None: + if not isinstance(unauthenticated, bool): + raise ValueError("'unauthenticated' must be a boolean.") + super().__init__(unauthenticated=unauthenticated) + else: + if not isinstance(auth_claims, dict): + raise ValueError("'auth_claims' must be a dictionary.") + super().__init__(authClaims=auth_claims) + + @staticmethod + def unauthenticated() -> 'Impersonation': + """Returns impersonation configuration for unauthenticated requests.""" + return Impersonation(unauthenticated=True) + + @staticmethod + def authenticated(auth_claims: Dict[str, Any]) -> 'Impersonation': + """Returns impersonation configuration for authenticated requests. + + # TODO: More strongly type auth_claims later. + """ + return Impersonation(auth_claims=auth_claims) + + +@dataclass +class GraphqlOptions(Generic[_Variables]): + variables: Optional[_Variables] = None + operation_name: Optional[str] = None + impersonate: Optional[Union[Impersonation, Dict[str, Any]]] = None + + +# TODO(b/406281627): Add support for partial errors. +@dataclass +class ExecuteGraphqlResponse(Generic[_Data]): + """Represents the response from a DataConnect GraphQL execution. + + Attributes: + data: The raw JSON dictionary returned by the GraphQL execution. + """ + data: _Data + + class DataConnect: + + """Represents a Firebase Data Connect client instance. This client provides access to the Firebase Data Connect service @@ -126,6 +192,71 @@ def app(self) -> App: def config(self) -> ConnectorConfig: return self._config + def execute_graphql( + self, + query: str, + options: Optional[GraphqlOptions[_Variables]] = None, + variables_type: Type[_Variables] = Any, + ) -> ExecuteGraphqlResponse[Any]: + """Executes a GraphQL query or mutation and returns the result. + + Args: + query: string containing the GraphQL query + options: GraphqlOptions instance containing operational parameters such as + variables, operation name, or impersonation context (optional). + variables_type: The expected structure for the request variables + + Returns: + ExecuteGraphqlResponse: An ExecuteGraphqlResponse containing the raw + response data dictionary. + + Raises: + ValueError: If the arguments are invalid from the local inputs side. + InvalidArgumentError: If GraphQL syntax validation fails on the server. + PermissionDeniedError: If an @auth policy directive blocks execution due to + insufficient permission. + NotFoundError: If a specified resource is not found, or the request is rejected + by undisclosed reasons, such as whitelisting. + InternalError: If the server response payload is invalid or malformed. + FirebaseError: The base platform exception. + """ + return self._client.execute_graphql( + query=query, options=options, variables_type=variables_type + ) + + def execute_graphql_read( + self, + query: str, + options: Optional[GraphqlOptions[_Variables]] = None, + variables_type: Type[_Variables] = Any, + ) -> ExecuteGraphqlResponse[Any]: + """Executes a read-only GraphQL query and returns the result. + + Args: + query: string containing the read-only GraphQL query + options: GraphqlOptions instance containing operational parameters such as + variables, operation name, or impersonation context (optional). + variables_type: The expected structure for the request variables + + Returns: + ExecuteGraphqlResponse: An ExecuteGraphqlResponse containing the raw + response data dictionary. + + Raises: + ValueError: If the arguments are invalid from the local inputs side. + InvalidArgumentError: If GraphQL syntax validation fails on the server. + PermissionDeniedError: If an @auth policy directive blocks execution due to + insufficient permission. + NotFoundError: If a specified resource is not found, or the request is rejected + by undisclosed reasons, such as whitelisting. + InternalError: If the server response payload is invalid or malformed. + FirebaseError: The base platform exception. + """ + return self._client.execute_graphql_read( + query=query, options=options, variables_type=variables_type + ) + + class _DataConnectService: """Service that maintains a collection of DataConnect clients.""" @@ -170,42 +301,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.""" - - @staticmethod - def unauthenticated() -> 'Impersonation': - """Returns impersonation configuration for unauthenticated requests.""" - return Impersonation(unauthenticated=True) - - @staticmethod - def authenticated(auth_claims: Dict[str, Any]) -> 'Impersonation': - """Returns impersonation configuration for authenticated requests. - - # TODO: More strongly type auth_claims later. - """ - return Impersonation(authClaims=auth_claims) - - -@dataclass -class GraphqlOptions(Generic[_Variables]): - variables: Optional[_Variables] = None - operation_name: Optional[str] = None - impersonate: Optional[Union[Impersonation, Dict[str, Any]]] = None - - -# TODO(b/406281627): Add support for partial errors. -@dataclass -class ExecuteGraphqlResponse(Generic[_Data]): - """Represents the response from a DataConnect GraphQL execution. - - Attributes: - data: The raw JSON dictionary returned by the GraphQL execution. - """ - data: _Data - - - def _get_emulator_host() -> Optional[str]: return _utils.get_emulator_host("DATA_CONNECT_EMULATOR_HOST") @@ -252,12 +347,18 @@ def _validate_variables_type( if variables is not None: if not (isinstance(variables, Mapping) or is_dataclass(variables)): raise ValueError("variables must be a collections.abc.Mapping or a dataclass") - if variable_type is not None: + if ( + variable_type is not None + and variable_type is not Any + and variable_type is not typing.Any + ): expected_type = typing.get_origin(variable_type) or variable_type if not isinstance(variables, expected_type): type_name = getattr(expected_type, '__name__', str(expected_type)) raise ValueError(f"variables must be of type {type_name}") + + def _validate_impersonation_options(self, impersonate: Any) -> None: """Validates impersonation dictionary options.""" if impersonate is not None: @@ -365,9 +466,10 @@ def _get_headers(self) -> Dict[str, str]: @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: + """Raises QueryError if the GraphQL response payload contains non-empty errors.""" + if isinstance(resp_dict, dict) and resp_dict.get("errors"): errors = resp_dict["errors"] + all_messages = "" if isinstance(errors, list): messages = [] @@ -422,3 +524,50 @@ def _parse_graphql_response( # TODO(b/406281627): Add support for partial errors. return ExecuteGraphqlResponse(data=resp_dict.get("data")) + + + def _execute_graphql_helper( + self, + query: str, + endpoint: str, + options: Optional[GraphqlOptions[_Variables]] = None, + variables_type: Type[_Variables] = Any, + ) -> ExecuteGraphqlResponse[Any]: + """Helper method to execute GraphQL queries or mutations against a specified endpoint.""" + if not isinstance(query, str): + raise ValueError("query must be a string") + query = query.strip() + if not query: + raise ValueError("query must be a non-empty string") + + self._validate_graphql_options(options, variable_type=variables_type) + + url = self._get_firebase_dataconnect_service_url(endpoint) + headers = self._get_headers() + payload = self._prepare_graphql_payload(query, options) + + resp_dict = self._make_gql_request(url=url, headers=headers, payload=payload) + return self._parse_graphql_response(resp_dict) + + + def execute_graphql( + self, + query: str, + options: Optional[GraphqlOptions[_Variables]] = None, + variables_type: Type[_Variables] = Any, + ) -> ExecuteGraphqlResponse[Any]: + """Executes a GraphQL query or mutation and returns the result.""" + return self._execute_graphql_helper( + query, _EXECUTE_GRAPHQL_ENDPOINT, options, variables_type + ) + + def execute_graphql_read( + self, + query: str, + options: Optional[GraphqlOptions[_Variables]] = None, + variables_type: Type[_Variables] = Any, + ) -> ExecuteGraphqlResponse[Any]: + """Executes a read-only GraphQL query and returns the result.""" + return self._execute_graphql_helper( + query, _EXECUTE_GRAPHQL_READ_ENDPOINT, options, variables_type + ) diff --git a/integration/emulators/dataconnect/connector/connector.yaml b/integration/emulators/dataconnect/connector/connector.yaml new file mode 100644 index 00000000..3b1bcdcc --- /dev/null +++ b/integration/emulators/dataconnect/connector/connector.yaml @@ -0,0 +1 @@ +connectorId: "my-connector" diff --git a/integration/emulators/dataconnect/connector/mutations.gql b/integration/emulators/dataconnect/connector/mutations.gql new file mode 100644 index 00000000..f3bfbeeb --- /dev/null +++ b/integration/emulators/dataconnect/connector/mutations.gql @@ -0,0 +1,102 @@ +mutation upsertFredUser @auth(level: NO_ACCESS) { + user_upsert(data: { id: "fred_id", address: "32 Elm St.", name: "Fred" }) +} +mutation updateFredrickUserImpersonation +@auth(level: USER, insecureReason: "test") { + user_update( + key: { id_expr: "auth.uid" } + data: { address: "64 Elm St. North", name: "Fredrick" } + ) +} +mutation upsertJeffUser @auth(level: NO_ACCESS) { + user_upsert(data: { id: "jeff_id", address: "99 Oak St.", name: "Jeff" }) +} + +mutation upsertJeffEmail @auth(level: NO_ACCESS) { + email_upsert( + data: { + id: "jeff_email_id" + subject: "free bitcoin inside" + date: "1999-12-31" + text: "get pranked! LOL!" + fromId: "jeff_id" + } + ) +} + +mutation InsertUser($id: String!, $name: String!, $address: String!) +@auth(level: PUBLIC, insecureReason: "test") { + user_insert(data: { id: $id, name: $name, address: $address }) +} + +mutation InsertEmailPublic($id: String!) +@auth(level: PUBLIC, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "PublicEmail" + date: "1999-12-31" + text: "PublicEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailUserAnon($id: String!) +@auth(level: USER_ANON, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "UserAnonEmail" + date: "1999-12-31" + text: "UserAnonEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailUser($id: String!) +@auth(level: USER, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "UserEmail" + date: "1999-12-31" + text: "UserEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailUserEmailVerified($id: String!) +@auth(level: USER_EMAIL_VERIFIED, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "UserEmailVerifiedEmail" + date: "1999-12-31" + text: "UserEmailVerifiedEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailNoAccess($id: String!) @auth(level: NO_ACCESS) { + email_insert( + data: { + id: $id + subject: "NoAccessEmail" + date: "1999-12-31" + text: "NoAccessEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailImpersonation($id: String!) +@auth(level: USER_ANON, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "ImpersonatedEmail" + date: "1999-12-31" + text: "ImpersonatedEmail" + fromId_expr: "auth.uid" + } + ) +} diff --git a/integration/emulators/dataconnect/connector/queries.gql b/integration/emulators/dataconnect/connector/queries.gql new file mode 100644 index 00000000..b4c3c922 --- /dev/null +++ b/integration/emulators/dataconnect/connector/queries.gql @@ -0,0 +1,75 @@ +query ListUsersPublic @auth(level: PUBLIC, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersUserAnon @auth(level: USER_ANON, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersUser @auth(level: USER, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersUserEmailVerified +@auth(level: USER_EMAIL_VERIFIED, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersNoAccess @auth(level: NO_ACCESS) { + users { + id + name + address + } +} +query ListUsersImpersonationAnon @auth(level: USER_ANON) { + users(where: { id: { eq_expr: "auth.uid" } }) { + id + name + address + } +} +query GetUser($id: User_Key!) @auth(level: PUBLIC, insecureReason: "test") { + user(key: $id) { + id + name + address + } +} + +query ListEmails @auth(level: NO_ACCESS) { + emails { + id + subject + text + date + from { + name + } + } +} +query GetEmail($id: String!) @auth(level: USER_ANON, insecureReason: "test") { + email(id: $id) { + id + subject + date + text + from { + id + name + address + } + } +} diff --git a/integration/emulators/dataconnect/dataconnect.yaml b/integration/emulators/dataconnect/dataconnect.yaml new file mode 100644 index 00000000..562964d8 --- /dev/null +++ b/integration/emulators/dataconnect/dataconnect.yaml @@ -0,0 +1,12 @@ +specVersion: "v1" +serviceId: "my-service" +location: "us-west2" +schema: + source: "./schema" + datasource: + postgresql: + database: "my-database" + cloudSql: + instanceId: "my-instance" +connectorDirs: + - "./connector" \ No newline at end of file diff --git a/integration/emulators/dataconnect/schema/schema.gql b/integration/emulators/dataconnect/schema/schema.gql new file mode 100644 index 00000000..405d593f --- /dev/null +++ b/integration/emulators/dataconnect/schema/schema.gql @@ -0,0 +1,13 @@ +type User @table(key: ["id"]) { + id: String! + name: String! + address: String! +} + +type Email @table { + id: String! + subject: String! + date: Date! + text: String! + from: User! +} diff --git a/integration/emulators/firebase.json b/integration/emulators/firebase.json index a7b727c4..7c2bff8a 100644 --- a/integration/emulators/firebase.json +++ b/integration/emulators/firebase.json @@ -1,5 +1,8 @@ { "emulators": { + "dataconnect": { + "port": 9399 + }, "tasks": { "port": 9499 }, @@ -11,6 +14,9 @@ "port": 5001 } }, + "dataconnect": { + "source": "dataconnect" + }, "functions": [ { "source": "functions", diff --git a/integration/emulators/seed.sh b/integration/emulators/seed.sh new file mode 100755 index 00000000..f64ecfdc --- /dev/null +++ b/integration/emulators/seed.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Shell script to seed the Firebase Data Connect emulator with initial test data. +set -e + +EMULATOR_HOST="${DATA_CONNECT_EMULATOR_HOST:-127.0.0.1:9399}" +ENDPOINT="http://${EMULATOR_HOST}/v1/projects/test-project/locations/us-west2/services/my-service:executeGraphql" + +echo "Seeding Data Connect emulator at ${ENDPOINT}..." + +send_gql_mutation() { + local payload="$1" + local response + response=$(curl -s -X POST "${ENDPOINT}" \ + -H "Authorization: Bearer owner" \ + -H "Content-Type: application/json" \ + -d "${payload}") + + if [ -z "${response}" ]; then + echo "Failed to receive response from Data Connect emulator at ${ENDPOINT}" >&2 + exit 1 + fi + + # Check if response contains non-empty GraphQL errors using python JSON parser + if ! python3 -c "import sys, json; d=json.loads(sys.argv[1]); sys.exit(0 if not d.get('errors') else 1)" "${response}"; then + echo "GraphQL error seeding data with payload ${payload}: ${response}" >&2 + exit 1 + fi +} + +# 1. Seed Fred User +send_gql_mutation '{"query": "mutation { user_upsert(data: { id: \"fred_id\", address: \"32 Elm St.\", name: \"Fred\" }) }"}' + +# 2. Seed Jeff User +send_gql_mutation '{"query": "mutation { user_upsert(data: { id: \"jeff_id\", address: \"99 Oak St.\", name: \"Jeff\" }) }"}' + +# 3. Seed Fred Email +send_gql_mutation '{"query": "mutation { email_upsert(data: { id: \"email_id\", subject: \"free bitcoin inside\", date: \"1999-12-31\", text: \"get pranked! LOL!\", fromId: \"fred_id\" }) }"}' + +echo "Successfully seeded initial test data (2 users, 1 email)!" diff --git a/integration/test_data_connect.py b/integration/test_data_connect.py new file mode 100644 index 00000000..98d25a04 --- /dev/null +++ b/integration/test_data_connect.py @@ -0,0 +1,326 @@ +# Copyright 2026 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for firebase_admin.dataconnect module (execute_graphql).""" + +import pytest + +from firebase_admin import dataconnect, exceptions + +CONNECTOR_CONFIG = dataconnect.ConnectorConfig( + location='us-west2', + service_id='my-service', + connector='my-connector' +) + +FRED_USER = {'id': 'fred_id', 'address': '32 Elm St.', 'name': 'Fred'} +FREDRICK_USER = { + 'id': FRED_USER['id'], + 'address': '64 Elm St. North', + 'name': 'Fredrick' +} +JEFF_USER = {'id': 'jeff_id', 'address': '99 Oak St.', 'name': 'Jeff'} + +FRED_EMAIL = { + 'id': 'email_id', + 'subject': 'free bitcoin inside', + 'date': '1999-12-31', + 'text': 'get pranked! LOL!', + 'from': {'id': FRED_USER['id']} +} + +INITIAL_STATE = { + 'users': [FRED_USER, JEFF_USER], + 'emails': [FRED_EMAIL] +} + +# Queries & Mutations +QUERY_LIST_USERS = ( + 'query ListUsers @auth(level: PUBLIC) { users { id, name, address } }' +) +QUERY_LIST_EMAILS = ( + 'query ListEmails @auth(level: NO_ACCESS) ' + '{ emails { id subject text date from { id } } }' +) +QUERY_GET_EMAIL = ( + 'query GetEmail($id: String!) @auth(level: NO_ACCESS) ' + '{ email(id: $id) { id subject text date from { id } } }' +) +QUERY_GET_USER_BY_ID = ( + 'query GetUser($id: User_Key!) { user(key: $id) { id name address } }' +) + +QUERY_LIST_USERS_IMPERSONATION = """ + query ListUsers @auth(level: USER) { + users(where: { id: { eq_expr: "auth.uid" } }) { id, name, address } + }""" + +MULTIPLE_QUERIES = f"{QUERY_LIST_USERS}\n{QUERY_LIST_EMAILS}" + +UPSERT_FRED_USER = f""" + mutation user {{ + user_upsert(data: {{id: "{FRED_USER['id']}", address: "{FRED_USER['address']}", name: "{FRED_USER['name']}"}}) + }}""" + +UPDATE_FREDRICK_USER_IMPERSONATED = f""" + mutation upsertFredrickUserImpersonated @auth(level: USER) {{ + user_update( + key: {{ id_expr: "auth.uid" }}, + data: {{ address: "{FREDRICK_USER['address']}", name: "{FREDRICK_USER['name']}" }} + ) + }}""" + +UPSERT_JEFF_USER = f""" + mutation user {{ + user_upsert(data: {{id: "{JEFF_USER['id']}", address: "{JEFF_USER['address']}", name: "{JEFF_USER['name']}"}}) + }}""" + +UPSERT_FRED_EMAIL = f""" + mutation email {{ + email_upsert(data: {{ + id:"{FRED_EMAIL['id']}", + subject: "{FRED_EMAIL['subject']}", + date: "{FRED_EMAIL['date']}", + text: "{FRED_EMAIL['text']}", + fromId: "{FRED_EMAIL['from']['id']}" + }}) + }}""" + +DELETE_ALL = """ + mutation delete { + email_deleteMany(all: true) + user_deleteMany(all: true) + }""" + +# Impersonation Options +OPTS_UNAUTHORIZED_CLAIMS = dataconnect.GraphqlOptions( + impersonate=dataconnect.Impersonation.unauthenticated() +) + +OPTS_AUTHORIZED_FRED_CLAIMS = dataconnect.GraphqlOptions( + impersonate=dataconnect.Impersonation.authenticated({ + 'sub': FRED_USER['id'] + }) +) + +OPTS_NON_EXISTING_CLAIMS = dataconnect.GraphqlOptions( + impersonate=dataconnect.Impersonation.authenticated({ + 'sub': 'non-existing-id', + 'email_verified': True + }) +) + + +class TestExecuteGraphql: + """Integration tests for execute_graphql method.""" + + def test_execute_graphql_mutation(self): + """Tests executing mutations via execute_graphql.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + fred_resp = dc_client.execute_graphql(UPSERT_FRED_USER) + assert fred_resp.data['user_upsert']['id'] == FRED_USER['id'] + + jeff_resp = dc_client.execute_graphql(UPSERT_JEFF_USER) + assert jeff_resp.data['user_upsert']['id'] == JEFF_USER['id'] + + upsert_email_resp = dc_client.execute_graphql(UPSERT_FRED_EMAIL) + email_id = upsert_email_resp.data['email_upsert']['id'] + assert email_id + + get_email_options = dataconnect.GraphqlOptions(variables={'id': email_id}) + query_email_resp = dc_client.execute_graphql( + QUERY_GET_EMAIL, options=get_email_options + ) + assert query_email_resp.data['email'] == FRED_EMAIL + + def test_execute_graphql_query(self): + """Tests executing a query via execute_graphql.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql(QUERY_LIST_USERS) + assert sorted(resp.data['users'], key=lambda x: x['id']) == sorted( + INITIAL_STATE['users'], key=lambda x: x['id'] + ) + + def test_execute_graphql_operation_name_multiple_queries(self): + """Tests operation_name with multi-query document.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + options = dataconnect.GraphqlOptions(operation_name='ListEmails') + resp = dc_client.execute_graphql(MULTIPLE_QUERIES, options=options) + assert resp.data['emails'] == INITIAL_STATE['emails'] + + def test_execute_graphql_query_error_missing_variables(self): + """Tests query error when required variables are missing.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + with pytest.raises(dataconnect.QueryError) as excinfo: + dc_client.execute_graphql(QUERY_GET_USER_BY_ID) + assert excinfo.value.code == 'query-error' + + def test_execute_graphql_query_with_variables(self): + """Tests query execution with variables.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + user_id = INITIAL_STATE['users'][0]['id'] + options = dataconnect.GraphqlOptions(variables={'id': {'id': user_id}}) + resp = dc_client.execute_graphql(QUERY_GET_USER_BY_ID, options=options) + assert resp.data['user'] == INITIAL_STATE['users'][0] + + +class TestExecuteGraphqlRead: + """Integration tests for execute_graphql_read method.""" + + def test_execute_graphql_read_query(self): + """Tests read-only query execution.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql_read(QUERY_LIST_USERS) + assert sorted(resp.data['users'], key=lambda x: x['id']) == sorted( + INITIAL_STATE['users'], key=lambda x: x['id'] + ) + + def test_execute_graphql_read_mutation_fails(self): + """Tests that execute_graphql_read rejects mutation queries.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + with pytest.raises(exceptions.PermissionDeniedError): + dc_client.execute_graphql_read(UPSERT_FRED_USER) + + +class TestExecuteGraphqlImpersonation: + """Integration tests for execute_graphql / execute_graphql_read impersonation.""" + + class TestUserAuthPolicy: + """Integration tests for @auth(level: USER) policy.""" + + def test_execute_graphql_read_impersonated_authenticated(self): + """Tests read query with authenticated impersonation.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql_read( + QUERY_LIST_USERS_IMPERSONATION, options=OPTS_AUTHORIZED_FRED_CLAIMS + ) + assert len(resp.data['users']) == 1 + assert resp.data['users'][0] == FRED_USER + + def test_execute_graphql_impersonated_authenticated(self): + """Tests query with authenticated impersonation.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql( + QUERY_LIST_USERS_IMPERSONATION, options=OPTS_AUTHORIZED_FRED_CLAIMS + ) + assert len(resp.data['users']) == 1 + assert resp.data['users'][0] == FRED_USER + + def test_execute_graphql_impersonated_unauthenticated_fails(self): + """Tests query with unauthenticated impersonation fails.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + with pytest.raises(exceptions.UnauthenticatedError): + dc_client.execute_graphql( + QUERY_LIST_USERS_IMPERSONATION, options=OPTS_UNAUTHORIZED_CLAIMS + ) + + def test_execute_graphql_impersonated_non_existing_claims(self): + """Tests query with non-existing user claims returns empty list.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql( + QUERY_LIST_USERS_IMPERSONATION, options=OPTS_NON_EXISTING_CLAIMS + ) + assert resp.data['users'] == [] + + def test_execute_graphql_impersonated_mutation_authenticated(self): + """Tests mutation with authenticated impersonation.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + update_resp = dc_client.execute_graphql( + UPDATE_FREDRICK_USER_IMPERSONATED, options=OPTS_AUTHORIZED_FRED_CLAIMS + ) + assert update_resp.data['user_update']['id'] == FRED_USER['id'] + + user_id = FRED_USER['id'] + query_options = dataconnect.GraphqlOptions(variables={'id': {'id': user_id}}) + query_resp = dc_client.execute_graphql(QUERY_GET_USER_BY_ID, options=query_options) + assert query_resp.data['user'] == FREDRICK_USER + dc_client.execute_graphql(UPSERT_FRED_USER) + + def test_execute_graphql_impersonated_mutation_unauthenticated_fails(self): + """Tests mutation with unauthenticated impersonation fails.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + with pytest.raises(exceptions.UnauthenticatedError): + dc_client.execute_graphql( + UPDATE_FREDRICK_USER_IMPERSONATED, options=OPTS_UNAUTHORIZED_CLAIMS + ) + + def test_execute_graphql_impersonated_mutation_non_existing_claims(self): + """Tests mutation with non-existing claims returns None.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql( + UPDATE_FREDRICK_USER_IMPERSONATED, options=OPTS_NON_EXISTING_CLAIMS + ) + assert resp.data['user_update'] is None + + + class TestPublicAuthPolicy: + """Integration tests for @auth(level: PUBLIC) policy.""" + + def test_impersonated_authenticated(self): + """Tests public query with authenticated claims.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql( + QUERY_LIST_USERS, options=OPTS_AUTHORIZED_FRED_CLAIMS + ) + assert sorted(resp.data['users'], key=lambda x: x['id']) == sorted( + INITIAL_STATE['users'], key=lambda x: x['id'] + ) + + def test_impersonated_unauthenticated(self): + """Tests public query with unauthenticated claims.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql( + QUERY_LIST_USERS, options=OPTS_UNAUTHORIZED_CLAIMS + ) + assert sorted(resp.data['users'], key=lambda x: x['id']) == sorted( + INITIAL_STATE['users'], key=lambda x: x['id'] + ) + + def test_impersonated_non_existing_claims(self): + """Tests public query with non-existing user claims.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + resp = dc_client.execute_graphql( + QUERY_LIST_USERS, options=OPTS_NON_EXISTING_CLAIMS + ) + assert sorted(resp.data['users'], key=lambda x: x['id']) == sorted( + INITIAL_STATE['users'], key=lambda x: x['id'] + ) + + + class TestNoAccessAuthPolicy: + """Integration tests for @auth(level: NO_ACCESS) policy.""" + + def test_impersonated_authenticated_fails(self): + """Tests no-access query with authenticated claims fails.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + with pytest.raises(exceptions.PermissionDeniedError): + dc_client.execute_graphql( + QUERY_LIST_EMAILS, options=OPTS_AUTHORIZED_FRED_CLAIMS + ) + + def test_impersonated_unauthenticated_fails(self): + """Tests no-access query with unauthenticated claims fails.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + with pytest.raises(exceptions.PermissionDeniedError): + dc_client.execute_graphql( + QUERY_LIST_EMAILS, options=OPTS_UNAUTHORIZED_CLAIMS + ) + + def test_impersonated_non_existing_claims_fails(self): + """Tests no-access query with non-existing user claims fails.""" + dc_client = dataconnect.client(CONNECTOR_CONFIG) + with pytest.raises(exceptions.PermissionDeniedError): + dc_client.execute_graphql( + QUERY_LIST_EMAILS, options=OPTS_NON_EXISTING_CLAIMS + ) diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index 255ce005..165a0f4a 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -18,7 +18,6 @@ from typing import Any, Dict, Mapping from unittest import mock - from google.auth import credentials as google_auth_credentials import pytest import requests @@ -28,13 +27,39 @@ from firebase_admin import dataconnect from tests import testutils - BASE_CONFIG = dataconnect.ConnectorConfig( service_id="starterproject", location="us-east4", connector="my_connector", ) +TEST_QUERY = "query { hello }" +TEST_RESPONSE_DATA = {"foo": "bar"} +TEST_URL = "https://example.com/endpoint" +TEST_HEADERS = {"key": "val"} +TEST_PAYLOAD = {"query": TEST_QUERY} +TEST_AUTH_CLAIMS = {"sub": "user_123"} +TEST_VARIABLES = {"foo": "bar"} + +@dataclass +class UserProfile: + address: str + phone: str + +@dataclass +class CreateUserVariables: + user_id: str + name: str + profile: UserProfile + +TEST_PROFILE = UserProfile(address="123 Road", phone="332-3233-0199") +TEST_DATACLASS_VARIABLES = CreateUserVariables( + user_id="1", name="Fred", profile=TEST_PROFILE +) + +@dataclass +class User: + name: str class TestConnectorConfig: @@ -119,7 +144,6 @@ def test_init_property_assignment(self): assert isinstance(data_connect_instance._client, dataconnect._DataConnectApiClient) # pylint: disable=protected-access - class TestDataConnectClientFactory: def teardown_method(self, method): @@ -204,7 +228,6 @@ def setup_method(self): ) self.service = dataconnect._DataConnectService(self.app) # pylint: disable=protected-access - def teardown_method(self, method): del method testutils.cleanup_apps() @@ -323,7 +346,6 @@ def setup_method(self): service_id="starterproject", location="us-east4", connector="my_connector" ) - def teardown_method(self, method): del method testutils.cleanup_apps() @@ -437,22 +459,7 @@ def test_validate_graphql_options_valid_impersonate(self): self.api_client._validate_graphql_options(options) def test_validate_graphql_options_valid_dataclass_variables(self): - @dataclass - class UserProfile: - address: str - phone: str - - @dataclass - class CreateUserVariables: - user_id: str - name: str - profile: UserProfile - - profile_val = UserProfile(address="123 Road", phone="332-3233-0199") - valid_variables = CreateUserVariables( - user_id="1", name="Fred", profile=profile_val - ) - options = dataconnect.GraphqlOptions(variables=valid_variables) + options = dataconnect.GraphqlOptions(variables=TEST_DATACLASS_VARIABLES) self.api_client._validate_graphql_options(options, CreateUserVariables) def test_validate_graphql_options_valid_mapping_variables(self): @@ -521,17 +528,6 @@ def test_validate_graphql_options_invalid_operation_name(self): self.api_client._validate_graphql_options(options) def test_validate_graphql_options_invalid_variables(self): - @dataclass - class UserProfile: - address: str - phone: str - - @dataclass - class CreateUserVariables: - user_id: str - name: str - profile: UserProfile - # Test invalid variable format (not Mapping or dataclass) options = dataconnect.GraphqlOptions(variables="invalid-string-format") msg = "variables must be a collections.abc.Mapping or a dataclass" @@ -539,22 +535,18 @@ class CreateUserVariables: self.api_client._validate_graphql_options(options) # Test valid Mapping format but type mismatch against expected dataclass type - options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) + options = dataconnect.GraphqlOptions(variables=TEST_VARIABLES) with pytest.raises(ValueError, match="variables must be of type CreateUserVariables"): self.api_client._validate_graphql_options(options, CreateUserVariables) # Test type mismatch when variable_type is a tuple of classes (no __name__ attribute) - options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) + options = dataconnect.GraphqlOptions(variables=TEST_VARIABLES) msg = r"variables must be of type \(\, \\)" with pytest.raises(ValueError, match=msg): self.api_client._validate_graphql_options(options, (list, tuple)) # Test type mismatch when a dataclass is passed but a Dict is expected - profile_val = UserProfile(address="123 Road", phone="332-3233-0199") - valid_variables = CreateUserVariables( - user_id="1", name="Fred", profile=profile_val - ) - options = dataconnect.GraphqlOptions(variables=valid_variables) + options = dataconnect.GraphqlOptions(variables=TEST_DATACLASS_VARIABLES) with pytest.raises(ValueError, match="variables must be of type dict"): self.api_client._validate_graphql_options(options, Dict[str, Any]) @@ -571,37 +563,22 @@ def teardown_method(self, method): testutils.cleanup_apps() def test_prepare_graphql_payload_only_query(self): - payload = self.api_client._prepare_graphql_payload("query { hello }", None) - assert payload == {"query": "query { hello }"} + payload = self.api_client._prepare_graphql_payload(TEST_QUERY, None) + assert payload == {"query": TEST_QUERY} def test_prepare_graphql_payload_with_variables(self): - options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) - payload = self.api_client._prepare_graphql_payload("query { hello }", options) + options = dataconnect.GraphqlOptions(variables=TEST_VARIABLES) + payload = self.api_client._prepare_graphql_payload(TEST_QUERY, options) assert payload == { - "query": "query { hello }", - "variables": {"foo": "bar"} + "query": TEST_QUERY, + "variables": TEST_VARIABLES } def test_prepare_graphql_payload_with_dataclass_variables(self): - @dataclass - class UserProfile: - address: str - phone: str - - @dataclass - class CreateUserVariables: - user_id: str - name: str - profile: UserProfile - - profile_val = UserProfile(address="123 Road", phone="332-3233-0199") - valid_variables = CreateUserVariables( - user_id="1", name="Fred", profile=profile_val - ) - options = dataconnect.GraphqlOptions(variables=valid_variables) - payload = self.api_client._prepare_graphql_payload("query { hello }", options) + options = dataconnect.GraphqlOptions(variables=TEST_DATACLASS_VARIABLES) + payload = self.api_client._prepare_graphql_payload(TEST_QUERY, options) assert payload == { - "query": "query { hello }", + "query": TEST_QUERY, "variables": { "user_id": "1", "name": "Fred", @@ -617,9 +594,9 @@ def test_prepare_graphql_payload_with_operation_name(self): self.api_client._validate_graphql_options(options) assert options.operation_name == " myOp " - payload = self.api_client._prepare_graphql_payload("query { hello }", options) + payload = self.api_client._prepare_graphql_payload(TEST_QUERY, options) assert payload == { - "query": "query { hello }", + "query": TEST_QUERY, "operationName": "myOp" } assert options.operation_name == " myOp " @@ -627,9 +604,9 @@ def test_prepare_graphql_payload_with_operation_name(self): def test_prepare_graphql_payload_with_impersonate_unauthenticated(self): imp_unauth = dataconnect.Impersonation.unauthenticated() options = dataconnect.GraphqlOptions(impersonate=imp_unauth) - payload = self.api_client._prepare_graphql_payload("query { hello }", options) + payload = self.api_client._prepare_graphql_payload(TEST_QUERY, options) assert payload == { - "query": "query { hello }", + "query": TEST_QUERY, "extensions": { "impersonate": {"unauthenticated": True} } @@ -640,41 +617,26 @@ def test_prepare_graphql_payload_with_impersonate_authenticated(self): {"sub": "authenticated-UUID"} ) options = dataconnect.GraphqlOptions(impersonate=imp_auth) - payload = self.api_client._prepare_graphql_payload("query { hello }", options) + payload = self.api_client._prepare_graphql_payload(TEST_QUERY, options) assert payload == { - "query": "query { hello }", + "query": TEST_QUERY, "extensions": { "impersonate": {"authClaims": {"sub": "authenticated-UUID"}} } } def test_prepare_graphql_payload_with_all_fields(self): - @dataclass - class UserProfile: - address: str - phone: str - - @dataclass - class CreateUserVariables: - user_id: str - name: str - profile: UserProfile - - profile_val = UserProfile(address="123 Road", phone="332-3233-0199") - valid_variables = CreateUserVariables( - user_id="1", name="Fred", profile=profile_val - ) imp_auth = dataconnect.Impersonation.authenticated( {"sub": "authenticated-UUID"} ) options = dataconnect.GraphqlOptions( - variables=valid_variables, + variables=TEST_DATACLASS_VARIABLES, operation_name="getUsers", impersonate=imp_auth ) - payload = self.api_client._prepare_graphql_payload("query { hello }", options) + payload = self.api_client._prepare_graphql_payload(TEST_QUERY, options) assert payload == { - "query": "query { hello }", + "query": TEST_QUERY, "operationName": "getUsers", "variables": { "user_id": "1", @@ -760,43 +722,31 @@ def teardown_method(self, method): 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"} + res = self.api_client._make_gql_request(TEST_URL, TEST_HEADERS, TEST_PAYLOAD) mock_body_and_response.assert_called_once_with( - "post", url=url, headers=headers, json=payload + "post", url=TEST_URL, headers=TEST_HEADERS, json=TEST_PAYLOAD ) + assert res == {"data": "val"} 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) + self.api_client._make_gql_request(None, TEST_HEADERS, TEST_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) + self.api_client._make_gql_request(TEST_URL, None, TEST_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) + self.api_client._make_gql_request(TEST_URL, TEST_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) + self.api_client._make_gql_request(TEST_URL, TEST_HEADERS, TEST_PAYLOAD) @mock.patch.object(_http_client.JsonHttpClient, "body_and_response") def test_make_gql_request_server_errors(self, mock_body_and_response): @@ -810,12 +760,9 @@ def test_make_gql_request_server_errors(self, mock_body_and_response): }, 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) + self.api_client._make_gql_request(TEST_URL, TEST_HEADERS, TEST_PAYLOAD) assert excinfo.value.code == "query-error" assert str(excinfo.value) == "First error. Second error." @@ -828,24 +775,19 @@ def test_make_gql_request_non_standard_errors(self, mock_body_and_response): {"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) + self.api_client._make_gql_request(TEST_URL, TEST_HEADERS, TEST_PAYLOAD) assert excinfo.value.code == "query-error" assert str(excinfo.value) == "GraphQL execution failed: String error message" mock_body_and_response.return_value = ( - {"errors": []}, + {"data": TEST_RESPONSE_DATA, "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." + res = self.api_client._make_gql_request(TEST_URL, TEST_HEADERS, TEST_PAYLOAD) + assert res == {"data": TEST_RESPONSE_DATA, "errors": []} class TestParseGraphqlResponse: @@ -880,7 +822,191 @@ 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: not-a-dict" ) + + +class TestImpersonation: + + def test_unauthenticated_factory(self): + """Tests factory method for unauthenticated impersonation.""" + imp = dataconnect.Impersonation.unauthenticated() + assert imp == {"unauthenticated": True} + + def test_authenticated_factory(self): + """Tests factory method for authenticated impersonation.""" + imp = dataconnect.Impersonation.authenticated(TEST_AUTH_CLAIMS) + assert imp == {"authClaims": TEST_AUTH_CLAIMS} + + def test_constructor_unauthenticated(self): + """Tests direct constructor with unauthenticated=True.""" + imp = dataconnect.Impersonation(unauthenticated=True) + assert imp == {"unauthenticated": True} + + def test_constructor_auth_claims(self): + """Tests direct constructor with auth_claims dict.""" + imp = dataconnect.Impersonation(auth_claims=TEST_AUTH_CLAIMS) + assert imp == {"authClaims": TEST_AUTH_CLAIMS} + + def test_constructor_neither_unauth_nor_claims_fails(self): + """Tests specifying neither unauthenticated nor claims raises ValueError.""" + with pytest.raises( + ValueError, + match="Impersonation requires either 'unauthenticated=True' or 'auth_claims'." + ): + dataconnect.Impersonation() + + def test_constructor_both_unauth_and_claims_fails(self): + """Tests specifying both unauthenticated and claims raises ValueError.""" + with pytest.raises( + ValueError, + match="Cannot specify both 'unauthenticated' and 'auth_claims'." + ): + dataconnect.Impersonation(unauthenticated=True, auth_claims={"sub": "123"}) + + def test_constructor_invalid_unauthenticated_type(self): + """Tests non-boolean unauthenticated raises ValueError.""" + with pytest.raises(ValueError, match="'unauthenticated' must be a boolean."): + dataconnect.Impersonation(unauthenticated="not-a-bool") + + def test_constructor_invalid_auth_claims_type(self): + """Tests non-dict auth_claims raises ValueError.""" + with pytest.raises(ValueError, match="'auth_claims' must be a dictionary."): + dataconnect.Impersonation(auth_claims="not-a-dict") + + +class TestDataConnectApiClientExecuteGraphql: + + 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_execute_graphql_invalid_query_type(self): + with pytest.raises(ValueError, match="query must be a string"): + self.api_client.execute_graphql(123) + + def test_execute_graphql_empty_query(self): + with pytest.raises(ValueError, match="query must be a non-empty string"): + self.api_client.execute_graphql(" ") + + def test_execute_graphql_read_invalid_query_type(self): + with pytest.raises(ValueError, match="query must be a string"): + self.api_client.execute_graphql_read(123) + + def test_execute_graphql_read_empty_query(self): + with pytest.raises(ValueError, match="query must be a non-empty string"): + self.api_client.execute_graphql_read(" ") + + def test_execute_graphql_invalid_options(self): + with pytest.raises(ValueError, match="options must be a GraphqlOptions instance"): + self.api_client.execute_graphql(TEST_QUERY, options="not-graphql-options") + + def test_execute_graphql_invalid_variables_type(self): + options = dataconnect.GraphqlOptions(variables={"name": "Fred"}) + with pytest.raises(ValueError, match="variables must be of type User"): + self.api_client.execute_graphql(TEST_QUERY, options=options, variables_type=User) + + @mock.patch.object(dataconnect._DataConnectApiClient, "_make_gql_request") + def test_execute_graphql_success(self, mock_make_gql_request): + mock_make_gql_request.return_value = {"data": TEST_RESPONSE_DATA} + res = self.api_client.execute_graphql(TEST_QUERY) + mock_make_gql_request.assert_called_once() + assert res.data == TEST_RESPONSE_DATA + + @mock.patch.object(dataconnect._DataConnectApiClient, "_make_gql_request") + def test_execute_graphql_with_dataclass_variables(self, mock_make_gql_request): + mock_make_gql_request.return_value = {"data": {"user": {"name": "Fred"}}} + user_var = User(name="Fred") + options = dataconnect.GraphqlOptions(variables=user_var) + res = self.api_client.execute_graphql( + "query CreateUser { user }", options=options, variables_type=User + ) + mock_make_gql_request.assert_called_once_with( + url=mock.ANY, + headers=mock.ANY, + payload={ + "query": "query CreateUser { user }", + "variables": {"name": "Fred"} + } + ) + assert res.data == {"user": {"name": "Fred"}} + + @mock.patch.object(dataconnect._DataConnectApiClient, "_make_gql_request") + def test_execute_graphql_read_success(self, mock_make_gql_request): + mock_make_gql_request.return_value = {"data": TEST_RESPONSE_DATA} + res = self.api_client.execute_graphql_read(TEST_QUERY) + mock_make_gql_request.assert_called_once() + assert res.data == TEST_RESPONSE_DATA + + @mock.patch.object(dataconnect._DataConnectApiClient, "_make_gql_request") + def test_execute_graphql_read_with_dataclass_variables(self, mock_make_gql_request): + mock_make_gql_request.return_value = {"data": {"user": {"name": "Fred"}}} + user_var = User(name="Fred") + options = dataconnect.GraphqlOptions(variables=user_var) + res = self.api_client.execute_graphql_read( + "query GetUser { user }", options=options, variables_type=User + ) + mock_make_gql_request.assert_called_once_with( + url=mock.ANY, + headers=mock.ANY, + payload={ + "query": "query GetUser { user }", + "variables": {"name": "Fred"} + } + ) + assert res.data == {"user": {"name": "Fred"}} + + @mock.patch.object(dataconnect._DataConnectApiClient, "_make_gql_request") + def test_execute_graphql_payload_omits_empty_fields(self, mock_make_gql_request): + mock_make_gql_request.return_value = {"data": TEST_RESPONSE_DATA} + self.api_client.execute_graphql(TEST_QUERY) + mock_make_gql_request.assert_called_once_with( + url=mock.ANY, + headers=mock.ANY, + payload={"query": TEST_QUERY} + ) + + @mock.patch.object(_http_client.JsonHttpClient, "body_and_response") + def test_execute_graphql_parses_graphql_errors(self, mock_body_and_response): + mock_response = mock.Mock(spec=requests.Response) + mock_body_and_response.return_value = ( + {"errors": [{"message": "Syntax error in GraphQL query"}]}, + mock_response + ) + with pytest.raises(dataconnect.QueryError) as excinfo: + self.api_client.execute_graphql("query { invalid }") + + assert "Syntax error in GraphQL query" in str(excinfo.value) + assert excinfo.value.code == dataconnect._QUERY_ERROR_CODE + + @mock.patch.object(_http_client.JsonHttpClient, "body_and_response") + def test_execute_graphql_malformed_response_payload(self, mock_body_and_response): + mock_response = mock.Mock(spec=requests.Response) + mock_body_and_response.return_value = ("invalid-string-payload", mock_response) + with pytest.raises(exceptions.InternalError) as excinfo: + self.api_client.execute_graphql(TEST_QUERY) + + assert excinfo.value.code == exceptions.INTERNAL + assert str(excinfo.value) == ( + "Response payload is not a valid JSON dictionary: invalid-string-payload" + ) + + @pytest.mark.parametrize("error_class", [ + exceptions.InvalidArgumentError, + exceptions.PermissionDeniedError, + exceptions.NotFoundError, + exceptions.UnknownError + ]) + @mock.patch.object(dataconnect._DataConnectApiClient, "_make_gql_request") + def test_execute_graphql_bubbles_http_exceptions(self, mock_make_gql_request, error_class): + mock_make_gql_request.side_effect = error_class("Mocked API error") + with pytest.raises(error_class, match="Mocked API error"): + self.api_client.execute_graphql(TEST_QUERY)