From db66f2d1726ad75520c3e93b405802708d03b318 Mon Sep 17 00:00:00 2001 From: mk2023 Date: Thu, 9 Jul 2026 14:11:46 -0700 Subject: [PATCH 01/10] feat(dataconnect): Add unit tests for DataConnect API Client helpers Added comprehensive unit tests to TestDataConnectApiClient covering client constructor, inputs validation, variables and impersonation serialization, and service URL construction. --- tests/test_data_connect.py | 282 +++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index c226b12b..4237d71e 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -331,3 +331,285 @@ def test_overall_client_retrieval_and_caching(self): assert client1_app2.app is self.app2 assert client1_app2.config is self.config1 assert client1_app2 is not client1a + + +class TestDataConnectApiClientConstructor: + + def setup_method(self): + self.cred = testutils.MockCredential() + + def teardown_method(self, method): + del method + testutils.cleanup_apps() + + def test_constructor_invalid_app(self): + with pytest.raises(ValueError, match="First argument passed to DataConnectApiClient must be a valid Firebase app instance."): + dataconnect._DataConnectApiClient(BASE_CONFIG, None) + + def test_constructor_missing_project_id(self): + class CredentialWithoutProjectId: + def get_credential(self): + return self + + app_no_project_id = firebase_admin.initialize_app(CredentialWithoutProjectId(), name="no-project-id-app") + try: + with pytest.raises(ValueError, match="Failed to determine project ID"): + dataconnect._DataConnectApiClient(BASE_CONFIG, app_no_project_id) + finally: + firebase_admin.delete_app(app_no_project_id) + + def test_constructor_connector_config(self): + app = firebase_admin.initialize_app(self.cred, options={'projectId': 'test-project'}) + api_client = dataconnect._DataConnectApiClient(BASE_CONFIG, app) + assert api_client._connector_config is BASE_CONFIG + + +class TestDataConnectApiClientValidateInputs: + + 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_validate_inputs_valid(self): + # Valid query, no options + self.api_client._validate_inputs("query { hello }", None) + + # Valid query, valid options + options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) + self.api_client._validate_inputs("query { hello }", options) + + def test_validate_inputs_valid_impersonate(self): + # Valid unauthenticated impersonation + options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.unauthenticated()) + self.api_client._validate_inputs("query { hello }", options) + + # Valid authenticated impersonation + options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.authenticated({"sub": "authenticated-UUID"})) + self.api_client._validate_inputs("query { hello }", options) + + def test_validate_inputs_valid_variables(self): + @dataclass + class User: + id: str + name: str + address: str + + @dataclass + class UsersResponse: + users: list[User] + + valid_variables = UsersResponse(users=[User(id="1", name="Fred", address="123 Road")]) + options = dataconnect.GraphqlOptions(variables=valid_variables) + self.api_client._validate_inputs("query { hello }", options, UsersResponse) + + def test_validate_inputs_invalid_query_type(self): + + with pytest.raises(ValueError, match="query must be a non-empty string"): + self.api_client._validate_inputs(None, None) + with pytest.raises(ValueError, match="query must be a non-empty string"): + self.api_client._validate_inputs(123, None) + + def test_validate_inputs_empty_query(self): + with pytest.raises(ValueError, match="query must be a non-empty string"): + self.api_client._validate_inputs("", None) + with pytest.raises(ValueError, match="query must be a non-empty string"): + self.api_client._validate_inputs(" ", None) + + def test_validate_inputs_invalid_options(self): + with pytest.raises(ValueError, match="options must be a GraphqlOptions instance"): + self.api_client._validate_inputs("query { hello }", "invalid-options") + + def test_validate_inputs_invalid_impersonate(self): + # impersonate must be dict + options = dataconnect.GraphqlOptions(impersonate="invalid") + with pytest.raises(ValueError, match="impersonate option must be a dictionary"): + self.api_client._validate_inputs("query { hello }", options) + + # impersonate must have either unauthenticated or authClaims + options = dataconnect.GraphqlOptions(impersonate={"invalid_key": True}) + with pytest.raises(ValueError, match="impersonate option must contain either 'unauthenticated' or 'authClaims'"): + self.api_client._validate_inputs("query { hello }", options) + + # unauthenticated must be boolean + options = dataconnect.GraphqlOptions(impersonate={"unauthenticated": "not-bool"}) + with pytest.raises(ValueError, match="'unauthenticated' claim must be a boolean"): + self.api_client._validate_inputs("query { hello }", options) + + # authClaims must be a dict + options = dataconnect.GraphqlOptions(impersonate={"authClaims": "not-dict"}) + with pytest.raises(ValueError, match="'authClaims' must be a dictionary"): + self.api_client._validate_inputs("query { hello }", options) + + def test_validate_inputs_invalid_operation_name(self): + options = dataconnect.GraphqlOptions(operation_name=123) + with pytest.raises(ValueError, match="operation_name must be a non-empty string"): + self.api_client._validate_inputs("query { hello }", options) + options = dataconnect.GraphqlOptions(operation_name="") + with pytest.raises(ValueError, match="operation_name must be a non-empty string"): + self.api_client._validate_inputs("query { hello }", options) + + + def test_validate_inputs_invalid_variables(self): + @dataclass + class User: + id: str + name: str + address: str + + @dataclass + class UsersResponse: + users: list[User] + + options = dataconnect.GraphqlOptions(variables="not-users-response") + with pytest.raises(ValueError, match="variables must be of type UsersResponse"): + self.api_client._validate_inputs("query { hello }", options, UsersResponse) + + +class TestDataConnectApiClientPrepareGraphqlPayload: + + 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_prepare_graphql_payload_only_query(self): + payload = self.api_client._prepare_graphql_payload("query { hello }", None) + assert payload == {"query": "query { hello }"} + + def test_prepare_graphql_payload_with_variables(self): + options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) + payload = self.api_client._prepare_graphql_payload("query { hello }", options) + assert payload == { + "query": "query { hello }", + "variables": {"foo": "bar"} + } + + def test_prepare_graphql_payload_with_dataclass_variables(self): + @dataclass + class User: + id: str + name: str + address: str + + @dataclass + class UsersResponse: + users: list[User] + + valid_variables = UsersResponse(users=[User(id="1", name="Fred", address="123 Road")]) + options = dataconnect.GraphqlOptions(variables=valid_variables) + payload = self.api_client._prepare_graphql_payload("query { hello }", options) + assert payload == { + "query": "query { hello }", + "variables": { + "users": [ + { + "id": "1", + "name": "Fred", + "address": "123 Road" + } + ] + } + } + + def test_prepare_graphql_payload_with_operation_name(self): + options = dataconnect.GraphqlOptions(operation_name="myOp") + payload = self.api_client._prepare_graphql_payload("query { hello }", options) + assert payload == { + "query": "query { hello }", + "operationName": "myOp" + } + + def test_prepare_graphql_payload_with_impersonate_unauthenticated(self): + options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.unauthenticated()) + payload = self.api_client._prepare_graphql_payload("query { hello }", options) + assert payload == { + "query": "query { hello }", + "extensions": { + "impersonate": {"unauthenticated": True} + } + } + + def test_prepare_graphql_payload_with_impersonate_authenticated(self): + options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.authenticated({"sub": "authenticated-UUID"})) + payload = self.api_client._prepare_graphql_payload("query { hello }", options) + assert payload == { + "query": "query { hello }", + "extensions": { + "impersonate": {"authClaims": {"sub": "authenticated-UUID"}} + } + } + + def test_prepare_graphql_payload_with_all_fields(self): + @dataclass + class User: + id: str + name: str + address: str + + @dataclass + class UsersResponse: + users: list[User] + + valid_variables = UsersResponse(users=[User(id="1", name="Fred", address="123 Road")]) + options = dataconnect.GraphqlOptions( + variables=valid_variables, + operation_name="getUsers", + impersonate=dataconnect.Impersonation.authenticated({"sub": "authenticated-UUID"}) + ) + payload = self.api_client._prepare_graphql_payload("query { hello }", options) + assert payload == { + "query": "query { hello }", + "operationName": "getUsers", + "variables": { + "users": [ + { + "id": "1", + "name": "Fred", + "address": "123 Road" + } + ] + }, + "extensions": { + "impersonate": {"authClaims": {"sub": "authenticated-UUID"}} + } + } + + +class TestDataConnectApiClientServiceUrl: + + 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_get_firebase_dataconnect_service_url_production(self): + url = self.api_client._get_firebase_dataconnect_service_url("executeGraphql") + expected = ( + "https://firebasedataconnect.googleapis.com/v1" + "/projects/test-project/locations/us-east4" + "/services/starterproject:executeGraphql" + ) + assert url == expected + + def test_get_firebase_dataconnect_service_url_emulator(self, monkeypatch): + monkeypatch.setenv("DATA_CONNECT_EMULATOR_HOST", "localhost:9399") + url = self.api_client._get_firebase_dataconnect_service_url("executeGraphql") + expected = ( + "http://localhost:9399/v1" + "/projects/test-project/locations/us-east4" + "/services/starterproject:executeGraphql" + ) + assert url == expected \ No newline at end of file From 81bc79f200426e9aa68cbc2a98de7062077b2726 Mon Sep 17 00:00:00 2001 From: mk2023 Date: Thu, 9 Jul 2026 14:54:05 -0700 Subject: [PATCH 02/10] feat(fdc): Add GraphQL request validation and URL builder Implemented _validate_inputs to validate queries, options, variables, and impersonation, and _prepare_graphql_payload to construct GraphQL JSON payloads. Implemented _get_firebase_dataconnect_service_url to build production and emulator service endpoint URLs. Added full unit test coverage for input validation, payload construction, and URL formatting. --- firebase_admin/dataconnect.py | 201 +++++++++++++++++++++++++++++++++- tests/test_data_connect.py | 83 +++++++++----- 2 files changed, 255 insertions(+), 29 deletions(-) diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 201e3b12..f22b651d 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -18,14 +18,40 @@ Firebase apps. """ -from dataclasses import dataclass -from typing import Dict, Optional +import os +from dataclasses import dataclass, asdict, is_dataclass +from typing import Any, Dict, Generic, Optional, TypeVar -from firebase_admin import _utils, App +from firebase_admin import _utils, _http_client, App + + +__all__ = [ + 'ConnectorConfig', + 'DataConnect', + 'client', + 'GraphqlOptions', + 'Impersonation', + 'ExecuteGraphqlResponse', +] -__all__ = ['ConnectorConfig', 'DataConnect', 'client'] _DATA_CONNECT_ATTRIBUTE = '_data_connect' +_DATA_CONNECT_PROD_URL = 'https://firebasedataconnect.googleapis.com' +_API_VERSION = 'v1' + +_SERVICES_URL_FORMAT = ( + '{host}/{version}/projects/{project_id}/locations/{location_id}' + '/services/{service_id}:{endpoint_id}' +) + +_EMULATOR_SERVICES_URL_FORMAT = ( + 'http://{host}/{version}/projects/{project_id}/locations/{location_id}' + '/services/{service_id}:{endpoint_id}' +) + +# Generic Type Parameters +_T = TypeVar("_T") +_V = TypeVar("_V") @dataclass(frozen=True) class ConnectorConfig: @@ -122,3 +148,170 @@ def client(config: ConnectorConfig, app: Optional[App] = None) -> DataConnect: dc_service = _utils.get_app_service(app, _DATA_CONNECT_ATTRIBUTE, _DataConnectService) return dc_service.get_client(config) + + +class Impersonation: + """Represents impersonation configuration for DataConnect requests.""" + + @staticmethod + def unauthenticated() -> Dict[str, bool]: + """Returns impersonation configuration for unauthenticated requests.""" + return {"unauthenticated": True} + + @staticmethod + def authenticated(auth_claims: Dict[str, Any]) -> Dict[str, Any]: + """Returns impersonation configuration for authenticated requests.""" + return {"authClaims": auth_claims} + + +@dataclass +class GraphqlOptions(Generic[_V]): + variables: Optional[_V] = None + operation_name: Optional[str] = None + impersonate: Optional[Impersonation] = None + + +@dataclass +class ExecuteGraphqlResponse(Generic[_T]): + data: _T + + +def _get_emulator_host() -> Optional[str]: + emulator_host = os.environ.get("DATA_CONNECT_EMULATOR_HOST") + if emulator_host: + if "//" in emulator_host: + raise ValueError( + f'Invalid DATA_CONNECT_EMULATOR_HOST: "{emulator_host}". It must follow format ' + '"host:port".' + ) + return emulator_host + return None + + +class _DataConnectApiClient: + """Internal client for sending requests to the Firebase Data Connect backend. + Attributes: + connector_config: The connector configuration specifying the service, + location, and connector name. + app: The Firebase App instance associated with this client. + """ + + def __init__(self, connector_config: ConnectorConfig, app: App) -> None: + if not isinstance(app, App): + raise ValueError( + 'First argument passed to DataConnectApiClient must be a valid ' + 'Firebase app instance.' + ) + self._connector_config = connector_config + self._app = app + + self._project_id = app.project_id + if not self._project_id: + raise ValueError( + 'Failed to determine project ID. Initialize the SDK with service ' + 'account credentials or set project ID as an app option. Alternatively, set the ' + 'GOOGLE_CLOUD_PROJECT environment variable.') + + self._emulator_host = _get_emulator_host() + if self._emulator_host: + self._credential = _utils.EmulatorAdminCredentials() + else: + self._credential = app.credential.get_credential() + + self._http_client = _http_client.JsonHttpClient(credential=self._credential) + + def _validate_inputs( + self, + graphql_query: str, + graphql_options: Optional[GraphqlOptions[Any]], + variable_type: Any = None + ) -> None: + """Validates query and GraphqlOptions inputs at runtime.""" + # Validate the Query + if not isinstance(graphql_query, str) or not graphql_query.strip(): + raise ValueError('query must be a non-empty string') + + # Validate Options (if they exist) + if graphql_options is not None: + if not isinstance(graphql_options, GraphqlOptions): + raise ValueError('options must be a GraphqlOptions instance') + + # Validate Variables against expected variable_type + variables = graphql_options.variables + if variables is not None and variable_type is not None: + if not isinstance(variables, variable_type): + raise ValueError(f"variables must be of type {variable_type.__name__}") + + # Validate Operation Name (if it exists) + operation_name = graphql_options.operation_name + if operation_name is not None: + if not isinstance(operation_name, str) or not operation_name.strip(): + raise ValueError('operation_name must be a non-empty string') + + # Validate Impersonation (if it exists) + impersonate = graphql_options.impersonate + if impersonate is not None: + if not isinstance(impersonate, dict): + raise ValueError('impersonate option must be a dictionary') + if 'unauthenticated' not in impersonate and 'authClaims' not in impersonate: + raise ValueError( + "impersonate option must contain either " + "'unauthenticated' or 'authClaims'" + ) + if 'unauthenticated' in impersonate: + if not isinstance(impersonate['unauthenticated'], bool): + raise ValueError("'unauthenticated' claim must be a boolean") + if 'authClaims' in impersonate: + if not isinstance(impersonate['authClaims'], dict): + raise ValueError("'authClaims' claim must be a dictionary") + + def _prepare_graphql_payload( + self, + graphql_query: str, + graphql_options: Optional[GraphqlOptions[Any]] + ) -> Dict[str, Any]: + """Serializes input query and options to JSON-compatible dictionary.""" + payload = { + "query": graphql_query + } + + if graphql_options is not None: + if graphql_options.variables is not None: + if is_dataclass(graphql_options.variables): + payload["variables"] = asdict(graphql_options.variables) + else: + payload["variables"] = graphql_options.variables + + if graphql_options.operation_name is not None: + payload["operationName"] = graphql_options.operation_name + + if graphql_options.impersonate is not None: + payload["extensions"] = { + "impersonate": graphql_options.impersonate + } + + return payload + + def _get_firebase_dataconnect_service_url(self, method_name: str) -> str: + """Build and return the URL for a Firebase Data Connect API method.""" + project_id = self._project_id + location = self._connector_config.location + service_id = self._connector_config.service_id + + if self._emulator_host: + return _EMULATOR_SERVICES_URL_FORMAT.format( + host=self._emulator_host, + version=_API_VERSION, + project_id=project_id, + location_id=location, + service_id=service_id, + endpoint_id=method_name + ) + return _SERVICES_URL_FORMAT.format( + host=_DATA_CONNECT_PROD_URL, + version=_API_VERSION, + project_id=project_id, + location_id=location, + service_id=service_id, + endpoint_id=method_name + ) diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index 4237d71e..8e2f5130 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -15,6 +15,8 @@ """Test cases for the firebase_admin.dataconnect module.""" from unittest import mock +from dataclasses import dataclass +from google.auth import credentials as google_auth_credentials import pytest import firebase_admin @@ -343,15 +345,25 @@ def teardown_method(self, method): testutils.cleanup_apps() def test_constructor_invalid_app(self): - with pytest.raises(ValueError, match="First argument passed to DataConnectApiClient must be a valid Firebase app instance."): + msg = ( + "First argument passed to DataConnectApiClient must be a valid " + "Firebase app instance." + ) + with pytest.raises(ValueError, match=msg): dataconnect._DataConnectApiClient(BASE_CONFIG, None) def test_constructor_missing_project_id(self): - class CredentialWithoutProjectId: + class CredentialWithoutProjectId(firebase_admin.credentials.Base): def get_credential(self): - return self - - app_no_project_id = firebase_admin.initialize_app(CredentialWithoutProjectId(), name="no-project-id-app") + class DummyGoogleCred(google_auth_credentials.Credentials): + def refresh(self, request): + pass + return DummyGoogleCred() + + app_no_project_id = firebase_admin.initialize_app( + CredentialWithoutProjectId(), + name="no-project-id-app" + ) try: with pytest.raises(ValueError, match="Failed to determine project ID"): dataconnect._DataConnectApiClient(BASE_CONFIG, app_no_project_id) @@ -368,7 +380,9 @@ class TestDataConnectApiClientValidateInputs: def setup_method(self): self.cred = testutils.MockCredential() - self.app = firebase_admin.initialize_app(self.cred, options={'projectId': 'test-project'}) + 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): @@ -385,17 +399,22 @@ def test_validate_inputs_valid(self): def test_validate_inputs_valid_impersonate(self): # Valid unauthenticated impersonation - options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.unauthenticated()) + imp_unauth = dataconnect.Impersonation.unauthenticated() + options = dataconnect.GraphqlOptions(impersonate=imp_unauth) self.api_client._validate_inputs("query { hello }", options) # Valid authenticated impersonation - options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.authenticated({"sub": "authenticated-UUID"})) + imp_auth = dataconnect.Impersonation.authenticated( + {"sub": "authenticated-UUID"} + ) + options = dataconnect.GraphqlOptions(impersonate=imp_auth) self.api_client._validate_inputs("query { hello }", options) + def test_validate_inputs_valid_variables(self): @dataclass class User: - id: str + user_id: str name: str address: str @@ -403,7 +422,8 @@ class User: class UsersResponse: users: list[User] - valid_variables = UsersResponse(users=[User(id="1", name="Fred", address="123 Road")]) + users_val = [User(user_id="1", name="Fred", address="123 Road")] + valid_variables = UsersResponse(users=users_val) options = dataconnect.GraphqlOptions(variables=valid_variables) self.api_client._validate_inputs("query { hello }", options, UsersResponse) @@ -432,7 +452,11 @@ def test_validate_inputs_invalid_impersonate(self): # impersonate must have either unauthenticated or authClaims options = dataconnect.GraphqlOptions(impersonate={"invalid_key": True}) - with pytest.raises(ValueError, match="impersonate option must contain either 'unauthenticated' or 'authClaims'"): + msg = ( + "impersonate option must contain either " + "'unauthenticated' or 'authClaims'" + ) + with pytest.raises(ValueError, match=msg): self.api_client._validate_inputs("query { hello }", options) # unauthenticated must be boolean @@ -442,7 +466,7 @@ def test_validate_inputs_invalid_impersonate(self): # authClaims must be a dict options = dataconnect.GraphqlOptions(impersonate={"authClaims": "not-dict"}) - with pytest.raises(ValueError, match="'authClaims' must be a dictionary"): + with pytest.raises(ValueError, match="'authClaims' claim must be a dictionary"): self.api_client._validate_inputs("query { hello }", options) def test_validate_inputs_invalid_operation_name(self): @@ -453,11 +477,10 @@ def test_validate_inputs_invalid_operation_name(self): with pytest.raises(ValueError, match="operation_name must be a non-empty string"): self.api_client._validate_inputs("query { hello }", options) - def test_validate_inputs_invalid_variables(self): @dataclass class User: - id: str + user_id: str name: str address: str @@ -496,7 +519,7 @@ def test_prepare_graphql_payload_with_variables(self): def test_prepare_graphql_payload_with_dataclass_variables(self): @dataclass class User: - id: str + user_id: str name: str address: str @@ -504,7 +527,8 @@ class User: class UsersResponse: users: list[User] - valid_variables = UsersResponse(users=[User(id="1", name="Fred", address="123 Road")]) + users_val = [User(user_id="1", name="Fred", address="123 Road")] + valid_variables = UsersResponse(users=users_val) options = dataconnect.GraphqlOptions(variables=valid_variables) payload = self.api_client._prepare_graphql_payload("query { hello }", options) assert payload == { @@ -512,7 +536,7 @@ class UsersResponse: "variables": { "users": [ { - "id": "1", + "user_id": "1", "name": "Fred", "address": "123 Road" } @@ -529,7 +553,8 @@ def test_prepare_graphql_payload_with_operation_name(self): } def test_prepare_graphql_payload_with_impersonate_unauthenticated(self): - options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.unauthenticated()) + imp_unauth = dataconnect.Impersonation.unauthenticated() + options = dataconnect.GraphqlOptions(impersonate=imp_unauth) payload = self.api_client._prepare_graphql_payload("query { hello }", options) assert payload == { "query": "query { hello }", @@ -539,7 +564,10 @@ def test_prepare_graphql_payload_with_impersonate_unauthenticated(self): } def test_prepare_graphql_payload_with_impersonate_authenticated(self): - options = dataconnect.GraphqlOptions(impersonate=dataconnect.Impersonation.authenticated({"sub": "authenticated-UUID"})) + imp_auth = dataconnect.Impersonation.authenticated( + {"sub": "authenticated-UUID"} + ) + options = dataconnect.GraphqlOptions(impersonate=imp_auth) payload = self.api_client._prepare_graphql_payload("query { hello }", options) assert payload == { "query": "query { hello }", @@ -551,7 +579,7 @@ def test_prepare_graphql_payload_with_impersonate_authenticated(self): def test_prepare_graphql_payload_with_all_fields(self): @dataclass class User: - id: str + user_id: str name: str address: str @@ -559,11 +587,15 @@ class User: class UsersResponse: users: list[User] - valid_variables = UsersResponse(users=[User(id="1", name="Fred", address="123 Road")]) + users_val = [User(user_id="1", name="Fred", address="123 Road")] + valid_variables = UsersResponse(users=users_val) + imp_auth = dataconnect.Impersonation.authenticated( + {"sub": "authenticated-UUID"} + ) options = dataconnect.GraphqlOptions( variables=valid_variables, operation_name="getUsers", - impersonate=dataconnect.Impersonation.authenticated({"sub": "authenticated-UUID"}) + impersonate=imp_auth ) payload = self.api_client._prepare_graphql_payload("query { hello }", options) assert payload == { @@ -572,7 +604,7 @@ class UsersResponse: "variables": { "users": [ { - "id": "1", + "user_id": "1", "name": "Fred", "address": "123 Road" } @@ -606,10 +638,11 @@ def test_get_firebase_dataconnect_service_url_production(self): def test_get_firebase_dataconnect_service_url_emulator(self, monkeypatch): monkeypatch.setenv("DATA_CONNECT_EMULATOR_HOST", "localhost:9399") - url = self.api_client._get_firebase_dataconnect_service_url("executeGraphql") + api_client = dataconnect._DataConnectApiClient(BASE_CONFIG, self.app) + url = api_client._get_firebase_dataconnect_service_url("executeGraphql") expected = ( "http://localhost:9399/v1" "/projects/test-project/locations/us-east4" "/services/starterproject:executeGraphql" ) - assert url == expected \ No newline at end of file + assert url == expected From e82de0fa0e7d2175476d89a94b1b11c1826cfed8 Mon Sep 17 00:00:00 2001 From: mk2023 Date: Thu, 9 Jul 2026 20:08:09 -0700 Subject: [PATCH 03/10] feat(fdc): Add request validation, payload builder, and service URL builder Implemented three helper functions in _DataConnectApiClient along with their corresponding implementations: - _validate_inputs: Validates the query structure, options, variables types, and custom impersonation claims. - _prepare_graphql_payload: Prepares the JSON payload for GraphQL calls, serializing variables (including nested dataclasses) and configuring optional extensions like impersonation. - _get_firebase_dataconnect_service_url: Formats the endpoint URL for execution, supporting both production host formats and local emulator host formats. Added comprehensive unit tests covering standard cases, invalid options, missing configurations, and emulator environments for each of the helper functions. This completes the first half of milestone 3. --- firebase_admin/dataconnect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index f22b651d..5c719dfd 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -34,7 +34,6 @@ 'ExecuteGraphqlResponse', ] - _DATA_CONNECT_ATTRIBUTE = '_data_connect' _DATA_CONNECT_PROD_URL = 'https://firebasedataconnect.googleapis.com' _API_VERSION = 'v1' @@ -190,6 +189,7 @@ def _get_emulator_host() -> Optional[str]: class _DataConnectApiClient: """Internal client for sending requests to the Firebase Data Connect backend. + Attributes: connector_config: The connector configuration specifying the service, location, and connector name. From 021f28c406150c9bdf51978696a6cde3ff4d99d1 Mon Sep 17 00:00:00 2001 From: mk2023 Date: Fri, 10 Jul 2026 10:10:08 -0700 Subject: [PATCH 04/10] feat(fdc): Add request headers builder Implemented `_get_headers` inside `_DataConnectApiClient` to build standard telemetry headers for outgoing HTTP requests. Specifically, it populates: - X-Firebase-Client: The current Python SDK version header. - x-goog-api-client: The Google telemetry metrics header. Added the `TestDataConnectApiClientGetHeaders` unit test suite to verify the return type and values. --- firebase_admin/dataconnect.py | 9 ++++++++- tests/test_data_connect.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 5c719dfd..9f123c7b 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -21,7 +21,7 @@ import os from dataclasses import dataclass, asdict, is_dataclass from typing import Any, Dict, Generic, Optional, TypeVar - +import firebase_admin from firebase_admin import _utils, _http_client, App @@ -315,3 +315,10 @@ def _get_firebase_dataconnect_service_url(self, method_name: str) -> str: service_id=service_id, endpoint_id=method_name ) + + def _get_headers(self) -> Dict[str, str]: + """Build and return the headers for a Firebase Data Connect API call.""" + return{ + "X-Firebase-Client": f"fire-admin-python/{firebase_admin.__version__}", + "x-goog-api-client": _utils.get_metrics_header(), + } \ No newline at end of file diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index 8e2f5130..93337bcd 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -646,3 +646,23 @@ def test_get_firebase_dataconnect_service_url_emulator(self, monkeypatch): "/services/starterproject:executeGraphql" ) assert url == expected + + +class TestDataConnectApiClientGetHeaders: + + 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_get_headers(self): + headers = self.api_client._get_headers() + 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() \ No newline at end of file From 2bc4b1bdf6ff5c21db4cce311f58d9e353ac3841 Mon Sep 17 00:00:00 2001 From: mk2023 Date: Fri, 10 Jul 2026 10:17:10 -0700 Subject: [PATCH 05/10] chore(fdc): Resolve linter warnings in Data Connect module and tests Fixed formatting and style issues highlighted by pylint: - Removed trailing whitespace in TestDataConnectApiClientGetHeaders test class. - Resolved missing final newline warning at the end of the test file. --- firebase_admin/dataconnect.py | 2 +- tests/test_data_connect.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 9f123c7b..706106c2 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -321,4 +321,4 @@ def _get_headers(self) -> Dict[str, str]: return{ "X-Firebase-Client": f"fire-admin-python/{firebase_admin.__version__}", "x-goog-api-client": _utils.get_metrics_header(), - } \ No newline at end of file + } diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index 93337bcd..f6b7a390 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -649,20 +649,20 @@ def test_get_firebase_dataconnect_service_url_emulator(self, monkeypatch): class TestDataConnectApiClientGetHeaders: - + 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_get_headers(self): headers = self.api_client._get_headers() 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() \ No newline at end of file + assert headers.get("x-goog-api-client") == _utils.get_metrics_header() From 8b29c8738790ed101b310fd6efb37e494123b21e Mon Sep 17 00:00:00 2001 From: mk2023 Date: Mon, 13 Jul 2026 17:09:02 -0700 Subject: [PATCH 06/10] feat(fdc): Refactored Impersonation types, validation, and emulator host utility - Inherited Impersonation from dict to resolve the type-checking mismatch for static methods while maintaining dictionary behavior at runtime. - Updated GraphqlOptions.impersonate type annotation to accept Union[Impersonation, Dict[str, Any]] to support both helper and raw dictionary inputs. - Extracted generic variable and impersonate validation into separate helper methods _validate_variables_type and _validate_impersonation_options. - Shared emulator host extraction and validation logic in _utils.get_emulator_host and reused it across dataconnect and functions. - Removed AuthClaims and FirebaseClaim TypedDicts to keep them as raw dicts for now. - Renamed generic TypeVars to private naming conventions (_Data and _Variables). --- firebase_admin/_utils.py | 15 +++++ firebase_admin/dataconnect.py | 113 +++++++++++++++++----------------- firebase_admin/functions.py | 10 +-- tests/test_data_connect.py | 89 ++++++++++++++------------ 4 files changed, 124 insertions(+), 103 deletions(-) diff --git a/firebase_admin/_utils.py b/firebase_admin/_utils.py index 0277b9e5..6d9185c6 100644 --- a/firebase_admin/_utils.py +++ b/firebase_admin/_utils.py @@ -15,6 +15,8 @@ """Internal utilities common to all modules.""" import json +import os +import re from platform import python_version from typing import Callable, Optional @@ -345,3 +347,16 @@ def __init__(self): def refresh(self, request): pass + + +def get_emulator_host(env_var_name: str) -> Optional[str]: + """Retrieves and validates the host from the specified emulator environment variable.""" + emulator_host = os.environ.get(env_var_name) + if emulator_host: + if not re.match(r'^(?:\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+):[0-9]+$', emulator_host): + raise ValueError( + f'Invalid {env_var_name}: "{emulator_host}". It must follow format ' + '"host:port".' + ) + return emulator_host + return None diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 706106c2..4b59e0e2 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -18,13 +18,11 @@ Firebase apps. """ -import os from dataclasses import dataclass, asdict, is_dataclass -from typing import Any, Dict, Generic, Optional, TypeVar +from typing import Any, Dict, Generic, Optional, TypeVar, Union import firebase_admin from firebase_admin import _utils, _http_client, App - __all__ = [ 'ConnectorConfig', 'DataConnect', @@ -49,8 +47,8 @@ ) # Generic Type Parameters -_T = TypeVar("_T") -_V = TypeVar("_V") +_Data = TypeVar("_Data") +_Variables = TypeVar("_Variables") @dataclass(frozen=True) class ConnectorConfig: @@ -149,42 +147,38 @@ def client(config: ConnectorConfig, app: Optional[App] = None) -> DataConnect: return dc_service.get_client(config) -class Impersonation: + +class Impersonation(dict): """Represents impersonation configuration for DataConnect requests.""" @staticmethod - def unauthenticated() -> Dict[str, bool]: + def unauthenticated() -> 'Impersonation': """Returns impersonation configuration for unauthenticated requests.""" - return {"unauthenticated": True} + return Impersonation(unauthenticated=True) @staticmethod - def authenticated(auth_claims: Dict[str, Any]) -> Dict[str, Any]: - """Returns impersonation configuration for authenticated requests.""" - return {"authClaims": auth_claims} + 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[_V]): - variables: Optional[_V] = None +class GraphqlOptions(Generic[_Variables]): + variables: Optional[_Variables] = None operation_name: Optional[str] = None - impersonate: Optional[Impersonation] = None + impersonate: Optional[Union[Impersonation, Dict[str, Any]]] = None @dataclass -class ExecuteGraphqlResponse(Generic[_T]): - data: _T +class ExecuteGraphqlResponse(Generic[_Data]): + data: _Data def _get_emulator_host() -> Optional[str]: - emulator_host = os.environ.get("DATA_CONNECT_EMULATOR_HOST") - if emulator_host: - if "//" in emulator_host: - raise ValueError( - f'Invalid DATA_CONNECT_EMULATOR_HOST: "{emulator_host}". It must follow format ' - '"host:port".' - ) - return emulator_host - return None + return _utils.get_emulator_host("DATA_CONNECT_EMULATOR_HOST") class _DataConnectApiClient: @@ -199,7 +193,7 @@ class _DataConnectApiClient: def __init__(self, connector_config: ConnectorConfig, app: App) -> None: if not isinstance(app, App): raise ValueError( - 'First argument passed to DataConnectApiClient must be a valid ' + 'Second argument passed to DataConnectApiClient must be a valid ' 'Firebase app instance.' ) self._connector_config = connector_config @@ -220,50 +214,59 @@ def __init__(self, connector_config: ConnectorConfig, app: App) -> None: self._http_client = _http_client.JsonHttpClient(credential=self._credential) - def _validate_inputs( + def _validate_variables_type(self, variables: Any, variable_type: Any) -> None: + """Validates variables against expected type.""" + if variables is not None and variable_type is not None: + if not isinstance(variables, variable_type): + raise ValueError(f"variables must be of type {variable_type.__name__}") + + def _validate_impersonation_options(self, impersonate: Any) -> None: + """Validates impersonation dictionary options.""" + if impersonate is not None: + if not isinstance(impersonate, dict): + raise ValueError('impersonate option must be a dictionary') + if 'unauthenticated' not in impersonate and 'authClaims' not in impersonate: + raise ValueError( + "impersonate option must contain either " + "'unauthenticated' or 'authClaims'" + ) + if 'unauthenticated' in impersonate and 'authClaims' in impersonate: + raise ValueError( + "impersonate option cannot contain both " + "'unauthenticated' and 'authClaims'" + ) + if 'unauthenticated' in impersonate: + if not isinstance(impersonate['unauthenticated'], bool): + raise ValueError("'unauthenticated' claim must be a boolean") + if 'authClaims' in impersonate: + if not isinstance(impersonate['authClaims'], dict): + raise ValueError("'authClaims' claim must be a dictionary") + + def _validate_graphql_options( self, - graphql_query: str, graphql_options: Optional[GraphqlOptions[Any]], variable_type: Any = None ) -> None: - """Validates query and GraphqlOptions inputs at runtime.""" - # Validate the Query - if not isinstance(graphql_query, str) or not graphql_query.strip(): - raise ValueError('query must be a non-empty string') - - # Validate Options (if they exist) + """Validates GraphqlOptions inputs at runtime.""" if graphql_options is not None: if not isinstance(graphql_options, GraphqlOptions): raise ValueError('options must be a GraphqlOptions instance') # Validate Variables against expected variable_type - variables = graphql_options.variables - if variables is not None and variable_type is not None: - if not isinstance(variables, variable_type): - raise ValueError(f"variables must be of type {variable_type.__name__}") + self._validate_variables_type(graphql_options.variables, variable_type) # Validate Operation Name (if it exists) operation_name = graphql_options.operation_name if operation_name is not None: - if not isinstance(operation_name, str) or not operation_name.strip(): + if not isinstance(operation_name, str): + raise ValueError('operation_name must be a string') + operation_name = operation_name.strip() + if not operation_name: raise ValueError('operation_name must be a non-empty string') + graphql_options.operation_name = operation_name # Validate Impersonation (if it exists) - impersonate = graphql_options.impersonate - if impersonate is not None: - if not isinstance(impersonate, dict): - raise ValueError('impersonate option must be a dictionary') - if 'unauthenticated' not in impersonate and 'authClaims' not in impersonate: - raise ValueError( - "impersonate option must contain either " - "'unauthenticated' or 'authClaims'" - ) - if 'unauthenticated' in impersonate: - if not isinstance(impersonate['unauthenticated'], bool): - raise ValueError("'unauthenticated' claim must be a boolean") - if 'authClaims' in impersonate: - if not isinstance(impersonate['authClaims'], dict): - raise ValueError("'authClaims' claim must be a dictionary") + self._validate_impersonation_options(graphql_options.impersonate) def _prepare_graphql_payload( self, @@ -318,7 +321,7 @@ def _get_firebase_dataconnect_service_url(self, method_name: str) -> str: def _get_headers(self) -> Dict[str, str]: """Build and return the headers for a Firebase Data Connect API call.""" - return{ + return { "X-Firebase-Client": f"fire-admin-python/{firebase_admin.__version__}", "x-goog-api-client": _utils.get_metrics_header(), } diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py index 66ba700b..b111d88f 100644 --- a/firebase_admin/functions.py +++ b/firebase_admin/functions.py @@ -18,7 +18,6 @@ from datetime import datetime, timedelta, timezone from urllib import parse import re -import os import json from base64 import b64encode from typing import Any, Optional, Dict @@ -62,14 +61,7 @@ _DEFAULT_LOCATION = 'us-central1' def _get_emulator_host() -> Optional[str]: - emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR) - if emulator_host: - if '//' in emulator_host: - raise ValueError( - f'Invalid {_EMULATOR_HOST_ENV_VAR}: "{emulator_host}". It must follow format ' - '"host:port".') - return emulator_host - return None + return _utils.get_emulator_host(_EMULATOR_HOST_ENV_VAR) def _get_functions_service(app) -> _FunctionsService: diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index f6b7a390..28039ef5 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -346,7 +346,7 @@ def teardown_method(self, method): def test_constructor_invalid_app(self): msg = ( - "First argument passed to DataConnectApiClient must be a valid " + "Second argument passed to DataConnectApiClient must be a valid " "Firebase app instance." ) with pytest.raises(ValueError, match=msg): @@ -375,8 +375,14 @@ def test_constructor_connector_config(self): api_client = dataconnect._DataConnectApiClient(BASE_CONFIG, app) assert api_client._connector_config is BASE_CONFIG + def test_constructor_emulator_host_invalid(self, monkeypatch): + monkeypatch.setenv("DATA_CONNECT_EMULATOR_HOST", "http://localhost:9399") + app = firebase_admin.initialize_app(self.cred, options={'projectId': 'test-project'}) + with pytest.raises(ValueError, match="Invalid DATA_CONNECT_EMULATOR_HOST"): + dataconnect._DataConnectApiClient(BASE_CONFIG, app) + -class TestDataConnectApiClientValidateInputs: +class TestDataConnectApiClientValidateGraphqlOptions: def setup_method(self): self.cred = testutils.MockCredential() @@ -389,29 +395,28 @@ def teardown_method(self, method): del method testutils.cleanup_apps() - def test_validate_inputs_valid(self): - # Valid query, no options - self.api_client._validate_inputs("query { hello }", None) + def test_validate_graphql_options_valid(self): + # Valid with no options + self.api_client._validate_graphql_options(None) - # Valid query, valid options + # Valid with options options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) - def test_validate_inputs_valid_impersonate(self): + def test_validate_graphql_options_valid_impersonate(self): # Valid unauthenticated impersonation imp_unauth = dataconnect.Impersonation.unauthenticated() options = dataconnect.GraphqlOptions(impersonate=imp_unauth) - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) # Valid authenticated impersonation imp_auth = dataconnect.Impersonation.authenticated( {"sub": "authenticated-UUID"} ) options = dataconnect.GraphqlOptions(impersonate=imp_auth) - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) - - def test_validate_inputs_valid_variables(self): + def test_validate_graphql_options_valid_variables(self): @dataclass class User: user_id: str @@ -425,30 +430,17 @@ class UsersResponse: users_val = [User(user_id="1", name="Fred", address="123 Road")] valid_variables = UsersResponse(users=users_val) options = dataconnect.GraphqlOptions(variables=valid_variables) - self.api_client._validate_inputs("query { hello }", options, UsersResponse) - - def test_validate_inputs_invalid_query_type(self): + self.api_client._validate_graphql_options(options, UsersResponse) - with pytest.raises(ValueError, match="query must be a non-empty string"): - self.api_client._validate_inputs(None, None) - with pytest.raises(ValueError, match="query must be a non-empty string"): - self.api_client._validate_inputs(123, None) - - def test_validate_inputs_empty_query(self): - with pytest.raises(ValueError, match="query must be a non-empty string"): - self.api_client._validate_inputs("", None) - with pytest.raises(ValueError, match="query must be a non-empty string"): - self.api_client._validate_inputs(" ", None) - - def test_validate_inputs_invalid_options(self): + def test_validate_graphql_options_invalid_options(self): with pytest.raises(ValueError, match="options must be a GraphqlOptions instance"): - self.api_client._validate_inputs("query { hello }", "invalid-options") + self.api_client._validate_graphql_options("invalid-options") - def test_validate_inputs_invalid_impersonate(self): + def test_validate_graphql_options_invalid_impersonate(self): # impersonate must be dict options = dataconnect.GraphqlOptions(impersonate="invalid") with pytest.raises(ValueError, match="impersonate option must be a dictionary"): - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) # impersonate must have either unauthenticated or authClaims options = dataconnect.GraphqlOptions(impersonate={"invalid_key": True}) @@ -457,27 +449,46 @@ def test_validate_inputs_invalid_impersonate(self): "'unauthenticated' or 'authClaims'" ) with pytest.raises(ValueError, match=msg): - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) # unauthenticated must be boolean options = dataconnect.GraphqlOptions(impersonate={"unauthenticated": "not-bool"}) with pytest.raises(ValueError, match="'unauthenticated' claim must be a boolean"): - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) # authClaims must be a dict options = dataconnect.GraphqlOptions(impersonate={"authClaims": "not-dict"}) with pytest.raises(ValueError, match="'authClaims' claim must be a dictionary"): - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) + + # impersonate cannot contain both unauthenticated and authClaims + options = dataconnect.GraphqlOptions( + impersonate={"unauthenticated": True, "authClaims": {"uid": "123"}} + ) + msg = ( + "impersonate option cannot contain both " + "'unauthenticated' and 'authClaims'" + ) + with pytest.raises(ValueError, match=msg): + self.api_client._validate_graphql_options(options) - def test_validate_inputs_invalid_operation_name(self): + def test_validate_graphql_options_invalid_operation_name(self): + # Test type validation options = dataconnect.GraphqlOptions(operation_name=123) - with pytest.raises(ValueError, match="operation_name must be a non-empty string"): - self.api_client._validate_inputs("query { hello }", options) + with pytest.raises(ValueError, match="operation_name must be a string"): + self.api_client._validate_graphql_options(options) + + # Test empty string validation options = dataconnect.GraphqlOptions(operation_name="") with pytest.raises(ValueError, match="operation_name must be a non-empty string"): - self.api_client._validate_inputs("query { hello }", options) + self.api_client._validate_graphql_options(options) + + # Test stripped whitespace validation + options = dataconnect.GraphqlOptions(operation_name=" ") + with pytest.raises(ValueError, match="operation_name must be a non-empty string"): + self.api_client._validate_graphql_options(options) - def test_validate_inputs_invalid_variables(self): + def test_validate_graphql_options_invalid_variables(self): @dataclass class User: user_id: str @@ -490,7 +501,7 @@ class UsersResponse: options = dataconnect.GraphqlOptions(variables="not-users-response") with pytest.raises(ValueError, match="variables must be of type UsersResponse"): - self.api_client._validate_inputs("query { hello }", options, UsersResponse) + self.api_client._validate_graphql_options(options, UsersResponse) class TestDataConnectApiClientPrepareGraphqlPayload: From 361336baa998ac64cfbaeedca004b8378629f53c Mon Sep 17 00:00:00 2001 From: mk2023 Date: Tue, 14 Jul 2026 13:54:04 -0700 Subject: [PATCH 07/10] feat(fdc): Enforce Mapping or dataclass for variables and type-annotate variable_type - Updated _validate_variables_type to validate that if variables are provided, they must be either a collections.abc.Mapping or a dataclass. - Type-annotated the variable_type parameter as Optional[Type[Any]] = None in both validation helper signatures. - Refactored variable unit tests to use a realistic CreateUserVariables (nesting a UserProfile dataclass) instead of response-like shapes. - Added a test case confirming that standard dictionaries (Mapping) are accepted as valid variables. --- firebase_admin/dataconnect.py | 20 ++++-- tests/test_data_connect.py | 113 ++++++++++++++++++++-------------- 2 files changed, 80 insertions(+), 53 deletions(-) diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 4b59e0e2..5f7c98cf 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -18,8 +18,9 @@ Firebase apps. """ +from collections.abc import Mapping from dataclasses import dataclass, asdict, is_dataclass -from typing import Any, Dict, Generic, Optional, TypeVar, Union +from typing import Any, Dict, Generic, Optional, Type, TypeVar, Union import firebase_admin from firebase_admin import _utils, _http_client, App @@ -214,11 +215,18 @@ def __init__(self, connector_config: ConnectorConfig, app: App) -> None: self._http_client = _http_client.JsonHttpClient(credential=self._credential) - def _validate_variables_type(self, variables: Any, variable_type: Any) -> None: + def _validate_variables_type( + self, + variables: Any, + variable_type: Optional[Type[Any]] = None + ) -> None: """Validates variables against expected type.""" - if variables is not None and variable_type is not None: - if not isinstance(variables, variable_type): - raise ValueError(f"variables must be of type {variable_type.__name__}") + 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 not isinstance(variables, variable_type): + raise ValueError(f"variables must be of type {variable_type.__name__}") def _validate_impersonation_options(self, impersonate: Any) -> None: """Validates impersonation dictionary options.""" @@ -245,7 +253,7 @@ def _validate_impersonation_options(self, impersonate: Any) -> None: def _validate_graphql_options( self, graphql_options: Optional[GraphqlOptions[Any]], - variable_type: Any = None + variable_type: Optional[Type[Any]] = None ) -> None: """Validates GraphqlOptions inputs at runtime.""" if graphql_options is not None: diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index 28039ef5..ad4aa064 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -399,8 +399,8 @@ def test_validate_graphql_options_valid(self): # Valid with no options self.api_client._validate_graphql_options(None) - # Valid with options - options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) + # Valid with default options (no arguments) + options = dataconnect.GraphqlOptions() self.api_client._validate_graphql_options(options) def test_validate_graphql_options_valid_impersonate(self): @@ -416,21 +416,28 @@ def test_validate_graphql_options_valid_impersonate(self): options = dataconnect.GraphqlOptions(impersonate=imp_auth) self.api_client._validate_graphql_options(options) - def test_validate_graphql_options_valid_variables(self): + def test_validate_graphql_options_valid_dataclass_variables(self): @dataclass - class User: - user_id: str - name: str + class UserProfile: address: str + phone: str @dataclass - class UsersResponse: - users: list[User] + class CreateUserVariables: + user_id: str + name: str + profile: UserProfile - users_val = [User(user_id="1", name="Fred", address="123 Road")] - valid_variables = UsersResponse(users=users_val) + 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) - self.api_client._validate_graphql_options(options, UsersResponse) + self.api_client._validate_graphql_options(options, CreateUserVariables) + + def test_validate_graphql_options_valid_mapping_variables(self): + options = dataconnect.GraphqlOptions(variables={"user_id": "1", "name": "Fred"}) + self.api_client._validate_graphql_options(options) def test_validate_graphql_options_invalid_options(self): with pytest.raises(ValueError, match="options must be a GraphqlOptions instance"): @@ -490,18 +497,26 @@ def test_validate_graphql_options_invalid_operation_name(self): def test_validate_graphql_options_invalid_variables(self): @dataclass - class User: - user_id: str - name: str + class UserProfile: address: str + phone: str @dataclass - class UsersResponse: - users: list[User] + 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" + with pytest.raises(ValueError, match=msg): + self.api_client._validate_graphql_options(options) - options = dataconnect.GraphqlOptions(variables="not-users-response") - with pytest.raises(ValueError, match="variables must be of type UsersResponse"): - self.api_client._validate_graphql_options(options, UsersResponse) + # Test valid Mapping format but type mismatch against expected dataclass type + options = dataconnect.GraphqlOptions(variables={"foo": "bar"}) + with pytest.raises(ValueError, match="variables must be of type CreateUserVariables"): + self.api_client._validate_graphql_options(options, CreateUserVariables) class TestDataConnectApiClientPrepareGraphqlPayload: @@ -529,29 +544,31 @@ def test_prepare_graphql_payload_with_variables(self): def test_prepare_graphql_payload_with_dataclass_variables(self): @dataclass - class User: - user_id: str - name: str + class UserProfile: address: str + phone: str @dataclass - class UsersResponse: - users: list[User] + class CreateUserVariables: + user_id: str + name: str + profile: UserProfile - users_val = [User(user_id="1", name="Fred", address="123 Road")] - valid_variables = UsersResponse(users=users_val) + 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) assert payload == { "query": "query { hello }", "variables": { - "users": [ - { - "user_id": "1", - "name": "Fred", - "address": "123 Road" - } - ] + "user_id": "1", + "name": "Fred", + "profile": { + "address": "123 Road", + "phone": "332-3233-0199" + } } } @@ -589,17 +606,20 @@ def test_prepare_graphql_payload_with_impersonate_authenticated(self): def test_prepare_graphql_payload_with_all_fields(self): @dataclass - class User: - user_id: str - name: str + class UserProfile: address: str + phone: str @dataclass - class UsersResponse: - users: list[User] + class CreateUserVariables: + user_id: str + name: str + profile: UserProfile - users_val = [User(user_id="1", name="Fred", address="123 Road")] - valid_variables = UsersResponse(users=users_val) + 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"} ) @@ -613,13 +633,12 @@ class UsersResponse: "query": "query { hello }", "operationName": "getUsers", "variables": { - "users": [ - { - "user_id": "1", - "name": "Fred", - "address": "123 Road" - } - ] + "user_id": "1", + "name": "Fred", + "profile": { + "address": "123 Road", + "phone": "332-3233-0199" + } }, "extensions": { "impersonate": {"authClaims": {"sub": "authenticated-UUID"}} From 10b5197c3eca069df302bb9ce27800da8d32e933 Mon Sep 17 00:00:00 2001 From: mk2023 Date: Wed, 15 Jul 2026 12:39:03 -0700 Subject: [PATCH 08/10] fix(fdc): Safely format variable_type mismatch error using getattr fallback --- firebase_admin/dataconnect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 5f7c98cf..4473e3b9 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -226,7 +226,8 @@ def _validate_variables_type( raise ValueError("variables must be a collections.abc.Mapping or a dataclass") if variable_type is not None: if not isinstance(variables, variable_type): - raise ValueError(f"variables must be of type {variable_type.__name__}") + type_name = getattr(variable_type, '__name__', str(variable_type)) + raise ValueError(f"variables must be of type {type_name}") def _validate_impersonation_options(self, impersonate: Any) -> None: """Validates impersonation dictionary options.""" From 367615f5428c293c2649407168f0d66d66fe808e Mon Sep 17 00:00:00 2001 From: mk2023 Date: Wed, 15 Jul 2026 13:18:34 -0700 Subject: [PATCH 09/10] fix(fdc): Safely format variable_type mismatch error messages - Fallback to str(variable_type) if variable_type lacks a __name__ attribute to prevent AttributeError crashes. - Added a unit test validating this fallback behavior for a tuple of types. --- firebase_admin/dataconnect.py | 2 +- tests/test_data_connect.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 4473e3b9..00b41805 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -280,7 +280,7 @@ def _validate_graphql_options( def _prepare_graphql_payload( self, graphql_query: str, - graphql_options: Optional[GraphqlOptions[Any]] + graphql_options: Optional[GraphqlOptions[_Variables]] ) -> Dict[str, Any]: """Serializes input query and options to JSON-compatible dictionary.""" payload = { diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index ad4aa064..896c749a 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -518,6 +518,12 @@ class CreateUserVariables: 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"}) + msg = r"variables must be of type \(\, \\)" + with pytest.raises(ValueError, match=msg): + self.api_client._validate_graphql_options(options, (list, tuple)) + class TestDataConnectApiClientPrepareGraphqlPayload: From 99325d9e7c10cedb1c87fe64f4c234192a68bc33 Mon Sep 17 00:00:00 2001 From: mk2023 Date: Fri, 17 Jul 2026 12:59:39 -0700 Subject: [PATCH 10/10] fix(fdc): Prevent mutation of GraphqlOptions and support underscores in emulator hostnames Refactored GraphqlOptions operationName validation and serialization to avoid mutating the original options object. Updated emulator hostname regex validation to support underscores in hostnames, and added/updated corresponding unit tests. --- firebase_admin/_utils.py | 2 +- firebase_admin/dataconnect.py | 6 ++-- tests/test_data_connect.py | 8 ++++- tests/test_utils.py | 55 +++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/test_utils.py diff --git a/firebase_admin/_utils.py b/firebase_admin/_utils.py index 6d9185c6..1c7c3337 100644 --- a/firebase_admin/_utils.py +++ b/firebase_admin/_utils.py @@ -353,7 +353,7 @@ def get_emulator_host(env_var_name: str) -> Optional[str]: """Retrieves and validates the host from the specified emulator environment variable.""" emulator_host = os.environ.get(env_var_name) if emulator_host: - if not re.match(r'^(?:\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+):[0-9]+$', emulator_host): + if not re.match(r'^(?:\[[a-fA-F0-9:]+\]|[a-zA-Z0-9._-]+):[0-9]+$', emulator_host): raise ValueError( f'Invalid {env_var_name}: "{emulator_host}". It must follow format ' '"host:port".' diff --git a/firebase_admin/dataconnect.py b/firebase_admin/dataconnect.py index 00b41805..4152a4f1 100644 --- a/firebase_admin/dataconnect.py +++ b/firebase_admin/dataconnect.py @@ -269,10 +269,8 @@ def _validate_graphql_options( if operation_name is not None: if not isinstance(operation_name, str): raise ValueError('operation_name must be a string') - operation_name = operation_name.strip() - if not operation_name: + if not operation_name.strip(): raise ValueError('operation_name must be a non-empty string') - graphql_options.operation_name = operation_name # Validate Impersonation (if it exists) self._validate_impersonation_options(graphql_options.impersonate) @@ -295,7 +293,7 @@ def _prepare_graphql_payload( payload["variables"] = graphql_options.variables if graphql_options.operation_name is not None: - payload["operationName"] = graphql_options.operation_name + payload["operationName"] = graphql_options.operation_name.strip() if graphql_options.impersonate is not None: payload["extensions"] = { diff --git a/tests/test_data_connect.py b/tests/test_data_connect.py index 896c749a..6d4887e7 100644 --- a/tests/test_data_connect.py +++ b/tests/test_data_connect.py @@ -19,11 +19,13 @@ from google.auth import credentials as google_auth_credentials import pytest + import firebase_admin from firebase_admin import _utils from firebase_admin import dataconnect from tests import testutils + BASE_CONFIG = dataconnect.ConnectorConfig( service_id="starterproject", location="us-east4", @@ -579,12 +581,16 @@ class CreateUserVariables: } def test_prepare_graphql_payload_with_operation_name(self): - options = dataconnect.GraphqlOptions(operation_name="myOp") + options = dataconnect.GraphqlOptions(operation_name=" myOp ") + self.api_client._validate_graphql_options(options) + assert options.operation_name == " myOp " + payload = self.api_client._prepare_graphql_payload("query { hello }", options) assert payload == { "query": "query { hello }", "operationName": "myOp" } + assert options.operation_name == " myOp " def test_prepare_graphql_payload_with_impersonate_unauthenticated(self): imp_unauth = dataconnect.Impersonation.unauthenticated() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..4d57e44a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,55 @@ +# 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. + +"""Test cases for the firebase_admin._utils module.""" + +import pytest +from firebase_admin import _utils + +class TestGetEmulatorHost: + + @pytest.mark.parametrize('host', [ + 'localhost:8080', + '127.0.0.1:8080', + '[::1]:8080', + '[2001:db8::1]:8080', + 'my-host:9000', + 'my_host:9000', + 'my.host.name:12345', + 'host_with_underscores:8080', + ]) + def test_get_emulator_host_valid(self, monkeypatch, host): + monkeypatch.setenv('TEST_EMULATOR_HOST', host) + assert _utils.get_emulator_host('TEST_EMULATOR_HOST') == host + + @pytest.mark.parametrize('host', [ + 'http://localhost:8080', + 'localhost', + '127.0.0.1', + '[::1]', + 'my_host', + 'localhost:abc', + 'localhost:', + ':8080', + 'invalid_host_name_with_chars$:8080', + 'host@name:8080', + ]) + def test_get_emulator_host_invalid(self, monkeypatch, host): + monkeypatch.setenv('TEST_EMULATOR_HOST', host) + with pytest.raises(ValueError, match='Invalid TEST_EMULATOR_HOST'): + _utils.get_emulator_host('TEST_EMULATOR_HOST') + + def test_get_emulator_host_not_set(self, monkeypatch): + monkeypatch.delenv('TEST_EMULATOR_HOST', raising=False) + assert _utils.get_emulator_host('TEST_EMULATOR_HOST') is None