Skip to content

Commit 926eb42

Browse files
committed
feat(fdc): Add execute_graphql and execute_graphql_read methods and emulator test setup
Implemented execute_graphql and execute_graphql_read methods on DataConnect and _DataConnectApiClient. Configured Data Connect emulator schema, connector, queries, mutations, seed script, and GitHub Actions CI workflow step.
1 parent e2f4b62 commit 926eb42

11 files changed

Lines changed: 308 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ jobs:
5454
run: firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py'
5555
- name: Run Functions emulator tests
5656
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'
57+
- name: Run Data Connect emulator tests
58+
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'
59+
5760
lint:
5861
runs-on: ubuntu-latest
5962
steps:

firebase_admin/dataconnect.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,9 @@ def execute_graphql(
226226
InternalError: If the server response payload is invalid or malformed.
227227
FirebaseError: The base platform exception.
228228
"""
229-
raise NotImplementedError
229+
return self._client.execute_graphql(
230+
query=query, options=options, variables_type=variables_type
231+
)
230232

231233
def execute_graphql_read(
232234
self,
@@ -256,7 +258,10 @@ def execute_graphql_read(
256258
InternalError: If the server response payload is invalid or malformed.
257259
FirebaseError: The base platform exception.
258260
"""
259-
raise NotImplementedError
261+
return self._client.execute_graphql_read(
262+
query=query, options=options, variables_type=variables_type
263+
)
264+
260265

261266

262267
class _DataConnectService:
@@ -348,12 +353,18 @@ def _validate_variables_type(
348353
if variables is not None:
349354
if not (isinstance(variables, Mapping) or is_dataclass(variables)):
350355
raise ValueError("variables must be a collections.abc.Mapping or a dataclass")
351-
if variable_type is not None:
356+
if (
357+
variable_type is not None
358+
and variable_type is not Any
359+
and variable_type is not typing.Any
360+
):
352361
expected_type = typing.get_origin(variable_type) or variable_type
353362
if not isinstance(variables, expected_type):
354363
type_name = getattr(expected_type, '__name__', str(expected_type))
355364
raise ValueError(f"variables must be of type {type_name}")
356365

366+
367+
357368
def _validate_impersonation_options(self, impersonate: Any) -> None:
358369
"""Validates impersonation dictionary options."""
359370
if impersonate is not None:
@@ -460,9 +471,10 @@ def _get_headers(self) -> Dict[str, str]:
460471

461472
@staticmethod
462473
def _check_graphql_errors(resp_dict: Any, resp: Any) -> None:
463-
"""Raises QueryError if the GraphQL response payload contains an errors key."""
464-
if isinstance(resp_dict, dict) and "errors" in resp_dict:
474+
"""Raises QueryError if the GraphQL response payload contains non-empty errors."""
475+
if isinstance(resp_dict, dict) and resp_dict.get("errors"):
465476
errors = resp_dict["errors"]
477+
466478
all_messages = ""
467479
if isinstance(errors, list):
468480
messages = []
@@ -527,7 +539,20 @@ def _execute_graphql_helper(
527539
variables_type: Type[_Variables] = Any,
528540
) -> ExecuteGraphqlResponse[Any]:
529541
"""Helper method to execute GraphQL queries or mutations against a specified endpoint."""
530-
raise NotImplementedError
542+
if not isinstance(query, str):
543+
raise ValueError("query must be a string")
544+
if not query.strip():
545+
raise ValueError("query must be a non-empty string")
546+
547+
self._validate_graphql_options(options, variable_type=variables_type)
548+
549+
url = self._get_firebase_dataconnect_service_url(endpoint)
550+
headers = self._get_headers()
551+
payload = self._prepare_graphql_payload(query, options)
552+
553+
resp_dict = self._make_gql_request(url=url, headers=headers, payload=payload)
554+
return self._parse_graphql_response(resp_dict)
555+
531556

532557
def execute_graphql(
533558
self,
@@ -536,7 +561,9 @@ def execute_graphql(
536561
variables_type: Type[_Variables] = Any,
537562
) -> ExecuteGraphqlResponse[Any]:
538563
"""Executes a GraphQL query or mutation and returns the result."""
539-
raise NotImplementedError
564+
return self._execute_graphql_helper(
565+
query, _EXECUTE_GRAPHQL_ENDPOINT, options, variables_type
566+
)
540567

541568
def execute_graphql_read(
542569
self,
@@ -545,4 +572,6 @@ def execute_graphql_read(
545572
variables_type: Type[_Variables] = Any,
546573
) -> ExecuteGraphqlResponse[Any]:
547574
"""Executes a read-only GraphQL query and returns the result."""
548-
raise NotImplementedError
575+
return self._execute_graphql_helper(
576+
query, _EXECUTE_GRAPHQL_READ_ENDPOINT, options, variables_type
577+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
connectorId: "my-connector"
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
mutation upsertFredUser @auth(level: NO_ACCESS) {
2+
user_upsert(data: { id: "fred_id", address: "32 Elm St.", name: "Fred" })
3+
}
4+
mutation updateFredrickUserImpersonation
5+
@auth(level: USER, insecureReason: "test") {
6+
user_update(
7+
key: { id_expr: "auth.uid" }
8+
data: { address: "64 Elm St. North", name: "Fredrick" }
9+
)
10+
}
11+
mutation upsertJeffUser @auth(level: NO_ACCESS) {
12+
user_upsert(data: { id: "jeff_id", address: "99 Oak St.", name: "Jeff" })
13+
}
14+
15+
mutation upsertJeffEmail @auth(level: NO_ACCESS) {
16+
email_upsert(
17+
data: {
18+
id: "jeff_email_id"
19+
subject: "free bitcoin inside"
20+
date: "1999-12-31"
21+
text: "get pranked! LOL!"
22+
fromId: "jeff_id"
23+
}
24+
)
25+
}
26+
27+
mutation InsertUser($id: String!, $name: String!, $address: String!)
28+
@auth(level: PUBLIC, insecureReason: "test") {
29+
user_insert(data: { id: $id, name: $name, address: $address })
30+
}
31+
32+
mutation InsertEmailPublic($id: String!)
33+
@auth(level: PUBLIC, insecureReason: "test") {
34+
email_insert(
35+
data: {
36+
id: $id
37+
subject: "PublicEmail"
38+
date: "1999-12-31"
39+
text: "PublicEmail"
40+
fromId: "jeff_id"
41+
}
42+
)
43+
}
44+
mutation InsertEmailUserAnon($id: String!)
45+
@auth(level: USER_ANON, insecureReason: "test") {
46+
email_insert(
47+
data: {
48+
id: $id
49+
subject: "UserAnonEmail"
50+
date: "1999-12-31"
51+
text: "UserAnonEmail"
52+
fromId: "jeff_id"
53+
}
54+
)
55+
}
56+
mutation InsertEmailUser($id: String!)
57+
@auth(level: USER, insecureReason: "test") {
58+
email_insert(
59+
data: {
60+
id: $id
61+
subject: "UserEmail"
62+
date: "1999-12-31"
63+
text: "UserEmail"
64+
fromId: "jeff_id"
65+
}
66+
)
67+
}
68+
mutation InsertEmailUserEmailVerified($id: String!)
69+
@auth(level: USER_EMAIL_VERIFIED, insecureReason: "test") {
70+
email_insert(
71+
data: {
72+
id: $id
73+
subject: "UserEmailVerifiedEmail"
74+
date: "1999-12-31"
75+
text: "UserEmailVerifiedEmail"
76+
fromId: "jeff_id"
77+
}
78+
)
79+
}
80+
mutation InsertEmailNoAccess($id: String!) @auth(level: NO_ACCESS) {
81+
email_insert(
82+
data: {
83+
id: $id
84+
subject: "NoAccessEmail"
85+
date: "1999-12-31"
86+
text: "NoAccessEmail"
87+
fromId: "jeff_id"
88+
}
89+
)
90+
}
91+
mutation InsertEmailImpersonation($id: String!)
92+
@auth(level: USER_ANON, insecureReason: "test") {
93+
email_insert(
94+
data: {
95+
id: $id
96+
subject: "ImpersonatedEmail"
97+
date: "1999-12-31"
98+
text: "ImpersonatedEmail"
99+
fromId_expr: "auth.uid"
100+
}
101+
)
102+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
query ListUsersPublic @auth(level: PUBLIC, insecureReason: "test") {
2+
users {
3+
id
4+
name
5+
address
6+
}
7+
}
8+
query ListUsersUserAnon @auth(level: USER_ANON, insecureReason: "test") {
9+
users {
10+
id
11+
name
12+
address
13+
}
14+
}
15+
query ListUsersUser @auth(level: USER, insecureReason: "test") {
16+
users {
17+
id
18+
name
19+
address
20+
}
21+
}
22+
query ListUsersUserEmailVerified
23+
@auth(level: USER_EMAIL_VERIFIED, insecureReason: "test") {
24+
users {
25+
id
26+
name
27+
address
28+
}
29+
}
30+
query ListUsersNoAccess @auth(level: NO_ACCESS) {
31+
users {
32+
id
33+
name
34+
address
35+
}
36+
}
37+
query ListUsersImpersonationAnon @auth(level: USER_ANON) {
38+
users(where: { id: { eq_expr: "auth.uid" } }) {
39+
id
40+
name
41+
address
42+
}
43+
}
44+
query GetUser($id: User_Key!) @auth(level: PUBLIC, insecureReason: "test") {
45+
user(key: $id) {
46+
id
47+
name
48+
address
49+
}
50+
}
51+
52+
query ListEmails @auth(level: NO_ACCESS) {
53+
emails {
54+
id
55+
subject
56+
text
57+
date
58+
from {
59+
name
60+
}
61+
}
62+
}
63+
query GetEmail($id: String!) @auth(level: USER_ANON, insecureReason: "test") {
64+
email(id: $id) {
65+
id
66+
subject
67+
date
68+
text
69+
from {
70+
id
71+
name
72+
address
73+
}
74+
}
75+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
specVersion: "v1"
2+
serviceId: "my-service"
3+
location: "us-west2"
4+
schema:
5+
source: "./schema"
6+
datasource:
7+
postgresql:
8+
database: "my-database"
9+
cloudSql:
10+
instanceId: "my-instance"
11+
connectorDirs:
12+
- "./connector"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
type User @table(key: ["id"]) {
2+
id: String!
3+
name: String!
4+
address: String!
5+
}
6+
7+
type Email @table {
8+
id: String!
9+
subject: String!
10+
date: Date!
11+
text: String!
12+
from: User!
13+
}

integration/emulators/firebase.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"emulators": {
3+
"dataconnect": {
4+
"port": 9399
5+
},
36
"tasks": {
47
"port": 9499
58
},
@@ -11,6 +14,9 @@
1114
"port": 5001
1215
}
1316
},
17+
"dataconnect": {
18+
"source": "dataconnect"
19+
},
1420
"functions": [
1521
{
1622
"source": "functions",

integration/emulators/seed.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/bin/bash
2+
# Shell script to seed the Firebase Data Connect emulator with initial test data.
3+
set -e
4+
5+
EMULATOR_HOST="${DATA_CONNECT_EMULATOR_HOST:-127.0.0.1:9399}"
6+
ENDPOINT="http://${EMULATOR_HOST}/v1/projects/test-project/locations/us-west2/services/my-service:executeGraphql"
7+
8+
echo "Seeding Data Connect emulator at ${ENDPOINT}..."
9+
10+
# 1. Seed Fred User
11+
curl -s -f -X POST "${ENDPOINT}" \
12+
-H "Authorization: Bearer owner" \
13+
-H "Content-Type: application/json" \
14+
-d '{"query": "mutation { user_upsert(data: { id: \"fred_id\", address: \"32 Elm St.\", name: \"Fred\" }) }"}' > /dev/null
15+
16+
# 2. Seed Jeff User
17+
curl -s -f -X POST "${ENDPOINT}" \
18+
-H "Authorization: Bearer owner" \
19+
-H "Content-Type: application/json" \
20+
-d '{"query": "mutation { user_upsert(data: { id: \"jeff_id\", address: \"99 Oak St.\", name: \"Jeff\" }) }"}' > /dev/null
21+
22+
# 3. Seed Fred Email
23+
curl -s -f -X POST "${ENDPOINT}" \
24+
-H "Authorization: Bearer owner" \
25+
-H "Content-Type: application/json" \
26+
-d '{"query": "mutation { email_upsert(data: { id: \"email_id\", subject: \"free bitcoin inside\", date: \"1999-12-31\", text: \"get pranked! LOL!\", fromId: \"fred_id\" }) }"}' > /dev/null
27+
28+
echo "Successfully seeded initial test data (2 users, 1 email)!"

0 commit comments

Comments
 (0)