Skip to content

Commit 602a2eb

Browse files
authored
feat(dataconnect): Implementation and Testing of the first half of _DataConnectApiClient class (#965)
*Core API Client Helpers* - Implemented `_validate_inputs` to enforce strict validation on query structures, options, variables, and custom impersonation claims. - Implemented `_prepare_graphql_payload` to construct the GraphQL JSON payload, seamlessly serializing variables (including nested dataclasses) and optional extensions. - Implemented `_get_firebase_dataconnect_service_url` to dynamically build endpoint URLs for both production and local emulator hosts. - Implemented `_get_headers` to inject standard telemetry metrics (X-Firebase-Client and x-goog-api-client). *Validation & Serialization Refinements* - Enforced that GraphQL variables must be either a Mapping (like standard dicts) or a dataclass. - Added support for runtime validation of subscripted generic types (e.g., Dict, Mapping) using typing.get_origin(). - Refactored `Impersonation` to inherit from `dict`, resolving type-checking mismatches while maintaining runtime dictionary behavior. Extracted variable and impersonation validation into dedicated helper methods. *Testing & Code Health* - Added comprehensive unit test coverage for the client constructor, input validation, headers, and payload serialization (using realistic nested dataclasses).
1 parent c84ce6f commit 602a2eb

5 files changed

Lines changed: 681 additions & 14 deletions

File tree

firebase_admin/_utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"""Internal utilities common to all modules."""
1616

1717
import json
18+
import os
19+
import re
1820
from platform import python_version
1921
from typing import Callable, Optional
2022

@@ -345,3 +347,16 @@ def __init__(self):
345347

346348
def refresh(self, request):
347349
pass
350+
351+
352+
def get_emulator_host(env_var_name: str) -> Optional[str]:
353+
"""Retrieves and validates the host from the specified emulator environment variable."""
354+
emulator_host = os.environ.get(env_var_name)
355+
if emulator_host:
356+
if not re.match(r'^(?:\[[a-fA-F0-9:]+\]|[a-zA-Z0-9._-]+):[0-9]+$', emulator_host):
357+
raise ValueError(
358+
f'Invalid {env_var_name}: "{emulator_host}". It must follow format '
359+
'"host:port".'
360+
)
361+
return emulator_host
362+
return None

firebase_admin/dataconnect.py

Lines changed: 217 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,39 @@
1818
Firebase apps.
1919
"""
2020

21-
from dataclasses import dataclass
22-
from typing import Dict, Optional
21+
from collections.abc import Mapping
22+
from dataclasses import dataclass, asdict, is_dataclass
23+
import typing
24+
from typing import Any, Dict, Generic, Optional, Type, TypeVar, Union
25+
import firebase_admin
26+
from firebase_admin import _utils, _http_client, App
2327

24-
from firebase_admin import _utils, App
25-
26-
__all__ = ['ConnectorConfig', 'DataConnect', 'client']
28+
__all__ = [
29+
'ConnectorConfig',
30+
'DataConnect',
31+
'client',
32+
'GraphqlOptions',
33+
'Impersonation',
34+
'ExecuteGraphqlResponse',
35+
]
2736

2837
_DATA_CONNECT_ATTRIBUTE = '_data_connect'
38+
_DATA_CONNECT_PROD_URL = 'https://firebasedataconnect.googleapis.com'
39+
_API_VERSION = 'v1'
40+
41+
_SERVICES_URL_FORMAT = (
42+
'{host}/{version}/projects/{project_id}/locations/{location_id}'
43+
'/services/{service_id}:{endpoint_id}'
44+
)
45+
46+
_EMULATOR_SERVICES_URL_FORMAT = (
47+
'http://{host}/{version}/projects/{project_id}/locations/{location_id}'
48+
'/services/{service_id}:{endpoint_id}'
49+
)
50+
51+
# Generic Type Parameters
52+
_Data = TypeVar("_Data")
53+
_Variables = TypeVar("_Variables")
2954

3055
@dataclass(frozen=True)
3156
class ConnectorConfig:
@@ -122,3 +147,190 @@ def client(config: ConnectorConfig, app: Optional[App] = None) -> DataConnect:
122147
dc_service = _utils.get_app_service(app, _DATA_CONNECT_ATTRIBUTE, _DataConnectService)
123148

124149
return dc_service.get_client(config)
150+
151+
152+
153+
class Impersonation(dict):
154+
"""Represents impersonation configuration for DataConnect requests."""
155+
156+
@staticmethod
157+
def unauthenticated() -> 'Impersonation':
158+
"""Returns impersonation configuration for unauthenticated requests."""
159+
return Impersonation(unauthenticated=True)
160+
161+
@staticmethod
162+
def authenticated(auth_claims: Dict[str, Any]) -> 'Impersonation':
163+
"""Returns impersonation configuration for authenticated requests.
164+
165+
# TODO: More strongly type auth_claims later.
166+
"""
167+
return Impersonation(authClaims=auth_claims)
168+
169+
170+
@dataclass
171+
class GraphqlOptions(Generic[_Variables]):
172+
variables: Optional[_Variables] = None
173+
operation_name: Optional[str] = None
174+
impersonate: Optional[Union[Impersonation, Dict[str, Any]]] = None
175+
176+
177+
@dataclass
178+
class ExecuteGraphqlResponse(Generic[_Data]):
179+
data: _Data
180+
181+
182+
def _get_emulator_host() -> Optional[str]:
183+
return _utils.get_emulator_host("DATA_CONNECT_EMULATOR_HOST")
184+
185+
186+
class _DataConnectApiClient:
187+
"""Internal client for sending requests to the Firebase Data Connect backend.
188+
189+
Attributes:
190+
connector_config: The connector configuration specifying the service,
191+
location, and connector name.
192+
app: The Firebase App instance associated with this client.
193+
"""
194+
195+
def __init__(self, connector_config: ConnectorConfig, app: App) -> None:
196+
if not isinstance(app, App):
197+
raise ValueError(
198+
'Second argument passed to DataConnectApiClient must be a valid '
199+
'Firebase app instance.'
200+
)
201+
self._connector_config = connector_config
202+
self._app = app
203+
204+
self._project_id = app.project_id
205+
if not self._project_id:
206+
raise ValueError(
207+
'Failed to determine project ID. Initialize the SDK with service '
208+
'account credentials or set project ID as an app option. Alternatively, set the '
209+
'GOOGLE_CLOUD_PROJECT environment variable.')
210+
211+
self._emulator_host = _get_emulator_host()
212+
if self._emulator_host:
213+
self._credential = _utils.EmulatorAdminCredentials()
214+
else:
215+
self._credential = app.credential.get_credential()
216+
217+
self._http_client = _http_client.JsonHttpClient(credential=self._credential)
218+
219+
def _validate_variables_type(
220+
self,
221+
variables: Any,
222+
variable_type: Optional[Type[Any]] = None
223+
) -> None:
224+
"""Validates variables against expected type."""
225+
if variables is not None:
226+
if not (isinstance(variables, Mapping) or is_dataclass(variables)):
227+
raise ValueError("variables must be a collections.abc.Mapping or a dataclass")
228+
if variable_type is not None:
229+
expected_type = typing.get_origin(variable_type) or variable_type
230+
if not isinstance(variables, expected_type):
231+
type_name = getattr(expected_type, '__name__', str(expected_type))
232+
raise ValueError(f"variables must be of type {type_name}")
233+
234+
def _validate_impersonation_options(self, impersonate: Any) -> None:
235+
"""Validates impersonation dictionary options."""
236+
if impersonate is not None:
237+
if not isinstance(impersonate, dict):
238+
raise ValueError('impersonate option must be a dictionary')
239+
if 'unauthenticated' not in impersonate and 'authClaims' not in impersonate:
240+
raise ValueError(
241+
"impersonate option must contain either "
242+
"'unauthenticated' or 'authClaims'"
243+
)
244+
if 'unauthenticated' in impersonate and 'authClaims' in impersonate:
245+
raise ValueError(
246+
"impersonate option cannot contain both "
247+
"'unauthenticated' and 'authClaims'"
248+
)
249+
if 'unauthenticated' in impersonate:
250+
if not isinstance(impersonate['unauthenticated'], bool):
251+
raise ValueError("'unauthenticated' claim must be a boolean")
252+
if 'authClaims' in impersonate:
253+
if not isinstance(impersonate['authClaims'], dict):
254+
raise ValueError("'authClaims' claim must be a dictionary")
255+
256+
def _validate_graphql_options(
257+
self,
258+
graphql_options: Optional[GraphqlOptions[Any]],
259+
variable_type: Optional[Type[Any]] = None
260+
) -> None:
261+
"""Validates GraphqlOptions inputs at runtime."""
262+
if graphql_options is not None:
263+
if not isinstance(graphql_options, GraphqlOptions):
264+
raise ValueError('options must be a GraphqlOptions instance')
265+
266+
# Validate Variables against expected variable_type
267+
self._validate_variables_type(graphql_options.variables, variable_type)
268+
269+
# Validate Operation Name (if it exists)
270+
operation_name = graphql_options.operation_name
271+
if operation_name is not None:
272+
if not isinstance(operation_name, str):
273+
raise ValueError('operation_name must be a string')
274+
if not operation_name.strip():
275+
raise ValueError('operation_name must be a non-empty string')
276+
277+
# Validate Impersonation (if it exists)
278+
self._validate_impersonation_options(graphql_options.impersonate)
279+
280+
def _prepare_graphql_payload(
281+
self,
282+
graphql_query: str,
283+
graphql_options: Optional[GraphqlOptions[_Variables]]
284+
) -> Dict[str, Any]:
285+
"""Serializes input query and options to JSON-compatible dictionary."""
286+
payload = {
287+
"query": graphql_query
288+
}
289+
290+
if graphql_options is not None:
291+
if graphql_options.variables is not None:
292+
if is_dataclass(graphql_options.variables):
293+
payload["variables"] = asdict(graphql_options.variables)
294+
else:
295+
payload["variables"] = graphql_options.variables
296+
297+
if graphql_options.operation_name is not None:
298+
payload["operationName"] = graphql_options.operation_name.strip()
299+
300+
if graphql_options.impersonate is not None:
301+
payload["extensions"] = {
302+
"impersonate": graphql_options.impersonate
303+
}
304+
305+
return payload
306+
307+
def _get_firebase_dataconnect_service_url(self, method_name: str) -> str:
308+
"""Build and return the URL for a Firebase Data Connect API method."""
309+
project_id = self._project_id
310+
location = self._connector_config.location
311+
service_id = self._connector_config.service_id
312+
313+
if self._emulator_host:
314+
return _EMULATOR_SERVICES_URL_FORMAT.format(
315+
host=self._emulator_host,
316+
version=_API_VERSION,
317+
project_id=project_id,
318+
location_id=location,
319+
service_id=service_id,
320+
endpoint_id=method_name
321+
)
322+
return _SERVICES_URL_FORMAT.format(
323+
host=_DATA_CONNECT_PROD_URL,
324+
version=_API_VERSION,
325+
project_id=project_id,
326+
location_id=location,
327+
service_id=service_id,
328+
endpoint_id=method_name
329+
)
330+
331+
def _get_headers(self) -> Dict[str, str]:
332+
"""Build and return the headers for a Firebase Data Connect API call."""
333+
return {
334+
"X-Firebase-Client": f"fire-admin-python/{firebase_admin.__version__}",
335+
"x-goog-api-client": _utils.get_metrics_header(),
336+
}

firebase_admin/functions.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from datetime import datetime, timedelta, timezone
1919
from urllib import parse
2020
import re
21-
import os
2221
import json
2322
from base64 import b64encode
2423
from typing import Any, Optional, Dict
@@ -62,14 +61,7 @@
6261
_DEFAULT_LOCATION = 'us-central1'
6362

6463
def _get_emulator_host() -> Optional[str]:
65-
emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR)
66-
if emulator_host:
67-
if '//' in emulator_host:
68-
raise ValueError(
69-
f'Invalid {_EMULATOR_HOST_ENV_VAR}: "{emulator_host}". It must follow format '
70-
'"host:port".')
71-
return emulator_host
72-
return None
64+
return _utils.get_emulator_host(_EMULATOR_HOST_ENV_VAR)
7365

7466

7567
def _get_functions_service(app) -> _FunctionsService:

0 commit comments

Comments
 (0)