Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
227 changes: 188 additions & 39 deletions firebase_admin/dataconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -101,7 +104,70 @@ def __post_init__(self):
raise ValueError("connector cannot be empty")


class Impersonation(dict):
Comment thread
mk2023 marked this conversation as resolved.
"""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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think there might still be a typo here - should be auth_claims=auth_claims right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was also confused about this, but the snake_case auth_claims is used in the __init__ signature and the authenticated() factory method so the public API remains purely Pythonic. However, because this class inherits from dict, the super().__init__(authClaims=...) is required to translate that pythonic argument into the exact camelCase authClaims JSON key that the Firebase Data Connect API expects. If we change the super().__init__ call to snake_case, the backend will reject the payload.

@stephenarosaj stephenarosaj Aug 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great call out - I think we should remedy this, and it shouldn't be too complex. This is how I'm thinking of it:

  • authClaims is what the backend API expects - this is how the data should be represented when it's turned into JSON and sent over the wire
  • auth_claims is the Pythonic, user-facing representation of that same data
  • If users were to print an instance of the Impersonation class at runtime, they would see authClaims - but this is the JSON representation, not the Python representation! We should instead be using the auth_claims representation here, since that's what users will interact with and it matches the platform via which they are interacting (Python)
  • We should keep the Python representation when in user-facing Python land, and use the JSON wire representation when communicating with the backend (instead of communicating with users) - this implies that we need some "translation" between the two
    • Lucky for us, we already have a _prepare_graphql_payload method which "Serializes input query and options to JSON-compatible dictionary" ;)


@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
Expand All @@ -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."""
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
):
Comment thread
mk2023 marked this conversation as resolved.
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:
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
)
1 change: 1 addition & 0 deletions integration/emulators/dataconnect/connector/connector.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
connectorId: "my-connector"
102 changes: 102 additions & 0 deletions integration/emulators/dataconnect/connector/mutations.gql
Original file line number Diff line number Diff line change
@@ -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"
}
)
}
Loading