-
Notifications
You must be signed in to change notification settings - Fork 356
feat(fdc): Add execute_graphql signatures and integration test suite #970
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: wine
Are you sure you want to change the base?
Changes from all commits
18a28c1
168105d
d300d30
28a50e2
1a1d12e
03fe605
e2f4b62
926eb42
c8e5d85
33138a7
b738f3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,9 @@ | |
| '/services/{service_id}:{endpoint_id}' | ||
| ) | ||
|
|
||
| _EXECUTE_GRAPHQL_ENDPOINT = 'executeGraphql' | ||
| _EXECUTE_GRAPHQL_READ_ENDPOINT = 'executeGraphqlRead' | ||
|
|
||
| # Generic Type Parameters | ||
| _Data = TypeVar("_Data") | ||
| _Variables = TypeVar("_Variables") | ||
|
|
@@ -101,7 +104,70 @@ def __post_init__(self): | |
| raise ValueError("connector cannot be empty") | ||
|
|
||
|
|
||
| class Impersonation(dict): | ||
| """Represents impersonation configuration for DataConnect requests. | ||
|
|
||
| It is recommended to construct instances using the static factory methods | ||
| :meth:`unauthenticated` or :meth:`authenticated`. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| unauthenticated: Optional[bool] = None, | ||
| auth_claims: Optional[Dict[str, Any]] = None | ||
| ) -> None: | ||
| if unauthenticated is None and auth_claims is None: | ||
| raise ValueError( | ||
| "Impersonation requires either 'unauthenticated=True' or 'auth_claims'." | ||
| ) | ||
| if unauthenticated is not None and auth_claims is not None: | ||
| raise ValueError("Cannot specify both 'unauthenticated' and 'auth_claims'.") | ||
|
|
||
| if unauthenticated is not None: | ||
| if not isinstance(unauthenticated, bool): | ||
| raise ValueError("'unauthenticated' must be a boolean.") | ||
| super().__init__(unauthenticated=unauthenticated) | ||
| else: | ||
| if not isinstance(auth_claims, dict): | ||
| raise ValueError("'auth_claims' must be a dictionary.") | ||
| super().__init__(authClaims=auth_claims) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. think there might still be a typo here - should be
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was also confused about this, but the snake_case There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
|
|
||
| @staticmethod | ||
| def unauthenticated() -> 'Impersonation': | ||
| """Returns impersonation configuration for unauthenticated requests.""" | ||
| return Impersonation(unauthenticated=True) | ||
|
|
||
| @staticmethod | ||
| def authenticated(auth_claims: Dict[str, Any]) -> 'Impersonation': | ||
| """Returns impersonation configuration for authenticated requests. | ||
|
|
||
| # TODO: More strongly type auth_claims later. | ||
| """ | ||
| return Impersonation(auth_claims=auth_claims) | ||
|
|
||
|
|
||
| @dataclass | ||
| class GraphqlOptions(Generic[_Variables]): | ||
| variables: Optional[_Variables] = None | ||
| operation_name: Optional[str] = None | ||
| impersonate: Optional[Union[Impersonation, Dict[str, Any]]] = None | ||
|
|
||
|
|
||
| # TODO(b/406281627): Add support for partial errors. | ||
| @dataclass | ||
| class ExecuteGraphqlResponse(Generic[_Data]): | ||
| """Represents the response from a DataConnect GraphQL execution. | ||
|
|
||
| Attributes: | ||
| data: The raw JSON dictionary returned by the GraphQL execution. | ||
| """ | ||
| data: _Data | ||
|
|
||
|
|
||
| class DataConnect: | ||
|
|
||
|
|
||
| """Represents a Firebase Data Connect client instance. | ||
|
|
||
| This client provides access to the Firebase Data Connect service | ||
|
|
@@ -126,6 +192,71 @@ def app(self) -> App: | |
| def config(self) -> ConnectorConfig: | ||
| return self._config | ||
|
|
||
| def execute_graphql( | ||
| self, | ||
| query: str, | ||
| options: Optional[GraphqlOptions[_Variables]] = None, | ||
| variables_type: Type[_Variables] = Any, | ||
| ) -> ExecuteGraphqlResponse[Any]: | ||
| """Executes a GraphQL query or mutation and returns the result. | ||
|
|
||
| Args: | ||
| query: string containing the GraphQL query | ||
| options: GraphqlOptions instance containing operational parameters such as | ||
| variables, operation name, or impersonation context (optional). | ||
| variables_type: The expected structure for the request variables | ||
|
|
||
| Returns: | ||
| ExecuteGraphqlResponse: An ExecuteGraphqlResponse containing the raw | ||
| response data dictionary. | ||
|
|
||
| Raises: | ||
| ValueError: If the arguments are invalid from the local inputs side. | ||
| InvalidArgumentError: If GraphQL syntax validation fails on the server. | ||
| PermissionDeniedError: If an @auth policy directive blocks execution due to | ||
| insufficient permission. | ||
| NotFoundError: If a specified resource is not found, or the request is rejected | ||
| by undisclosed reasons, such as whitelisting. | ||
| InternalError: If the server response payload is invalid or malformed. | ||
| FirebaseError: The base platform exception. | ||
| """ | ||
| return self._client.execute_graphql( | ||
| query=query, options=options, variables_type=variables_type | ||
| ) | ||
|
|
||
| def execute_graphql_read( | ||
| self, | ||
| query: str, | ||
| options: Optional[GraphqlOptions[_Variables]] = None, | ||
| variables_type: Type[_Variables] = Any, | ||
| ) -> ExecuteGraphqlResponse[Any]: | ||
| """Executes a read-only GraphQL query and returns the result. | ||
|
|
||
| Args: | ||
| query: string containing the read-only GraphQL query | ||
| options: GraphqlOptions instance containing operational parameters such as | ||
| variables, operation name, or impersonation context (optional). | ||
| variables_type: The expected structure for the request variables | ||
|
|
||
| Returns: | ||
| ExecuteGraphqlResponse: An ExecuteGraphqlResponse containing the raw | ||
| response data dictionary. | ||
|
|
||
| Raises: | ||
| ValueError: If the arguments are invalid from the local inputs side. | ||
| InvalidArgumentError: If GraphQL syntax validation fails on the server. | ||
| PermissionDeniedError: If an @auth policy directive blocks execution due to | ||
| insufficient permission. | ||
| NotFoundError: If a specified resource is not found, or the request is rejected | ||
| by undisclosed reasons, such as whitelisting. | ||
| InternalError: If the server response payload is invalid or malformed. | ||
| FirebaseError: The base platform exception. | ||
| """ | ||
| return self._client.execute_graphql_read( | ||
| query=query, options=options, variables_type=variables_type | ||
| ) | ||
|
|
||
|
|
||
|
|
||
| class _DataConnectService: | ||
| """Service that maintains a collection of DataConnect clients.""" | ||
|
|
@@ -170,42 +301,6 @@ def client(config: ConnectorConfig, app: Optional[App] = None) -> DataConnect: | |
| return dc_service.get_client(config) | ||
|
|
||
|
|
||
| class Impersonation(dict): | ||
| """Represents impersonation configuration for DataConnect requests.""" | ||
|
|
||
| @staticmethod | ||
| def unauthenticated() -> 'Impersonation': | ||
| """Returns impersonation configuration for unauthenticated requests.""" | ||
| return Impersonation(unauthenticated=True) | ||
|
|
||
| @staticmethod | ||
| def authenticated(auth_claims: Dict[str, Any]) -> 'Impersonation': | ||
| """Returns impersonation configuration for authenticated requests. | ||
|
|
||
| # TODO: More strongly type auth_claims later. | ||
| """ | ||
| return Impersonation(authClaims=auth_claims) | ||
|
|
||
|
|
||
| @dataclass | ||
| class GraphqlOptions(Generic[_Variables]): | ||
| variables: Optional[_Variables] = None | ||
| operation_name: Optional[str] = None | ||
| impersonate: Optional[Union[Impersonation, Dict[str, Any]]] = None | ||
|
|
||
|
|
||
| # TODO(b/406281627): Add support for partial errors. | ||
| @dataclass | ||
| class ExecuteGraphqlResponse(Generic[_Data]): | ||
| """Represents the response from a DataConnect GraphQL execution. | ||
|
|
||
| Attributes: | ||
| data: The raw JSON dictionary returned by the GraphQL execution. | ||
| """ | ||
| data: _Data | ||
|
|
||
|
|
||
|
|
||
| def _get_emulator_host() -> Optional[str]: | ||
| return _utils.get_emulator_host("DATA_CONNECT_EMULATOR_HOST") | ||
|
|
||
|
|
@@ -252,12 +347,18 @@ def _validate_variables_type( | |
| if variables is not None: | ||
| if not (isinstance(variables, Mapping) or is_dataclass(variables)): | ||
| raise ValueError("variables must be a collections.abc.Mapping or a dataclass") | ||
| if variable_type is not None: | ||
| if ( | ||
| variable_type is not None | ||
| and variable_type is not Any | ||
| and variable_type is not typing.Any | ||
| ): | ||
|
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: | ||
|
|
@@ -365,9 +466,10 @@ def _get_headers(self) -> Dict[str, str]: | |
|
|
||
| @staticmethod | ||
| def _check_graphql_errors(resp_dict: Any, resp: Any) -> None: | ||
| """Raises QueryError if the GraphQL response payload contains an errors key.""" | ||
| if isinstance(resp_dict, dict) and "errors" in resp_dict: | ||
| """Raises QueryError if the GraphQL response payload contains non-empty errors.""" | ||
| if isinstance(resp_dict, dict) and resp_dict.get("errors"): | ||
| errors = resp_dict["errors"] | ||
|
|
||
| all_messages = "" | ||
| if isinstance(errors, list): | ||
| messages = [] | ||
|
|
@@ -422,3 +524,50 @@ def _parse_graphql_response( | |
|
|
||
| # TODO(b/406281627): Add support for partial errors. | ||
| return ExecuteGraphqlResponse(data=resp_dict.get("data")) | ||
|
|
||
|
|
||
| def _execute_graphql_helper( | ||
| self, | ||
| query: str, | ||
| endpoint: str, | ||
| options: Optional[GraphqlOptions[_Variables]] = None, | ||
| variables_type: Type[_Variables] = Any, | ||
| ) -> ExecuteGraphqlResponse[Any]: | ||
| """Helper method to execute GraphQL queries or mutations against a specified endpoint.""" | ||
| if not isinstance(query, str): | ||
| raise ValueError("query must be a string") | ||
| query = query.strip() | ||
| if not query: | ||
| raise ValueError("query must be a non-empty string") | ||
|
|
||
| self._validate_graphql_options(options, variable_type=variables_type) | ||
|
|
||
| url = self._get_firebase_dataconnect_service_url(endpoint) | ||
| headers = self._get_headers() | ||
| payload = self._prepare_graphql_payload(query, options) | ||
|
|
||
| resp_dict = self._make_gql_request(url=url, headers=headers, payload=payload) | ||
| return self._parse_graphql_response(resp_dict) | ||
|
|
||
|
|
||
| def execute_graphql( | ||
| self, | ||
| query: str, | ||
| options: Optional[GraphqlOptions[_Variables]] = None, | ||
| variables_type: Type[_Variables] = Any, | ||
| ) -> ExecuteGraphqlResponse[Any]: | ||
| """Executes a GraphQL query or mutation and returns the result.""" | ||
| return self._execute_graphql_helper( | ||
| query, _EXECUTE_GRAPHQL_ENDPOINT, options, variables_type | ||
| ) | ||
|
|
||
| def execute_graphql_read( | ||
| self, | ||
| query: str, | ||
| options: Optional[GraphqlOptions[_Variables]] = None, | ||
| variables_type: Type[_Variables] = Any, | ||
| ) -> ExecuteGraphqlResponse[Any]: | ||
| """Executes a read-only GraphQL query and returns the result.""" | ||
| return self._execute_graphql_helper( | ||
| query, _EXECUTE_GRAPHQL_READ_ENDPOINT, options, variables_type | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| connectorId: "my-connector" |
| 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" | ||
| } | ||
| ) | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.