From cc0a6dd437d040c0e471190c3a20a20d3f0815b5 Mon Sep 17 00:00:00 2001 From: Lisa Jian Date: Mon, 8 Sep 2025 17:12:24 -0700 Subject: [PATCH 1/6] Add Firebase Data Connect support --- src/firebase_functions/dataconnect_fn.py | 285 +++++++++++++++++++++++ src/firebase_functions/options.py | 67 ++++++ tests/test_dataconnect_fn.py | 127 ++++++++++ 3 files changed, 479 insertions(+) create mode 100644 src/firebase_functions/dataconnect_fn.py create mode 100644 tests/test_dataconnect_fn.py diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py new file mode 100644 index 00000000..86b93c20 --- /dev/null +++ b/src/firebase_functions/dataconnect_fn.py @@ -0,0 +1,285 @@ +# Copyright 2022 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. +""" +Module for Cloud Functions that are triggered by Firebase Data Connect. +""" + +# pylint: disable=protected-access +import dataclasses as _dataclass +import datetime as _dt +import functools as _functools +import typing as _typing + +import cloudevents.http as _ce + +import firebase_functions.core as _core +import firebase_functions.private.path_pattern as _path_pattern +import firebase_functions.private.util as _util +from firebase_functions.options import DataConnectOptions + +_event_type_mutation_executed = "google.firebase.dataconnect.connector.v1.mutationExecuted" + + +@_dataclass.dataclass(frozen=True) +class Event(_core.CloudEvent[_core.T]): + """ + A CloudEvent that contains MutationEventData. + """ + + location: str + """ + The location of the database. + """ + + project: str + """ + The project identifier. + """ + + params: dict[str, str] + """ + A dict containing the values of the path patterns. + Only named capture groups are populated - {key}, {key=*}, {key=**} + """ + + +@_dataclass.dataclass(frozen=True) +class GraphqlErrorExtensions: + """ + GraphqlErrorExtensions contains additional information of `GraphqlError`. + """ + + file: str + """ + The source file name where the error occurred. + Included only for `UpdateSchema` and `UpdateConnector`, it corresponds + to `File.path` of the provided `Source`. + """ + + code: str + """ + Maps to canonical gRPC codes. + If not specified, it represents `Code.INTERNAL`. + """ + + debug_details: str + """ + More detailed error message to assist debugging. + It contains application business logic that are inappropriate to leak + publicly. + + In the emulator, Data Connect API always includes it to assist local + development and debugging. + In the backend, ConnectorService always hides it. + GraphqlService without impersonation always include it. + GraphqlService with impersonation includes it only if explicitly opted-in + with `include_debug_details` in `GraphqlRequestExtensions`. + """ + + +@_dataclass.dataclass(frozen=True) +class SourceLocation: + """ + SourceLocation references a location in a GraphQL source. + """ + + line: int + """ + Line number starting at 1. + """ + + column: int + """ + Column number starting at 1. + """ + + +@_dataclass.dataclass(frozen=True) +class GraphQLError: + """ + An error that occurred during the execution of a GraphQL request. + """ + + message: str + """ + A string describing the error. + """ + + locations: list[dict[str, int]] | None = None + """ + The source locations where the error occurred. + Locations should help developers and toolings identify the source of error + quickly. + + Included in admin endpoints (`ExecuteGraphql`, `ExecuteGraphqlRead`, + `UpdateSchema` and `UpdateConnector`) to reference the provided GraphQL + GQL document. + + Omitted in `ExecuteMutation` and `ExecuteQuery` since the caller shouldn't + have access access the underlying GQL source. + """ + + path: list[str | int] | None = None + """ + The result field which could not be populated due to error. + + Clients can use path to identify whether a null result is intentional or + caused by a runtime error. + It should be a list of string or index from the root of GraphQL query + document. + """ + + extensions: GraphqlErrorExtensions | None = None + + +@_dataclass.dataclass(frozen=True) +class Mutation: + """ + An object within Firebase Data Connect. + """ + + data: _typing.Any + """ + The result of the execution of the requested operation. + If an error was raised before execution begins, the data entry should not + be present in the result. (a request error: + https://spec.graphql.org/draft/#sec-Errors.Request-Errors) If an error was + raised during the execution that prevented a valid response, the data entry + in the response should be null. (a field error: + https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format) + """ + + variables: _typing.Any + """ + Values for GraphQL variables provided in this request. + """ + + errors: list[GraphQLError] | None = None + """ + Errors of this response. + If the data entry in the response is not present, the errors entry must be + present. + It conforms to https://spec.graphql.org/draft/#sec-Errors. + """ + +@_dataclass.dataclass(frozen=True) +class MutationEventData: + """ + The data within all Mutation events. + """ + + payload: Mutation + +_E1 = Event[MutationEventData] +_C1 = _typing.Callable[[_E1], None] + + +def _dataconnect_endpoint_handler( + func: _C1, + event_type: str, + service_pattern: _path_pattern.PathPattern, + connector_pattern: _path_pattern.PathPattern, + operation_pattern: _path_pattern.PathPattern, + raw: _ce.CloudEvent, +) -> None: + # Currently, only mutationExecuted is supported + assert event_type == _event_type_mutation_executed + + event_attributes = raw._get_attributes() + event_data: _typing.Any = raw.get_data() + + dataconnect_event_data = event_data + + event_service = event_attributes["service"] + event_connector = event_attributes["connector"] + event_operation = event_attributes["operation"] + params: dict[str, str] = { + **service_pattern.extract_matches(event_service), + **connector_pattern.extract_matches(event_connector), + **operation_pattern.extract_matches(event_operation), + } + + dataconnect_event = Event( + specversion=event_attributes["specversion"], + id=event_attributes["id"], + source=event_attributes["source"], + type=event_attributes["type"], + time=_dt.datetime.strptime( + event_attributes["time"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ), + subject=event_attributes.get("subject"), + location=event_attributes["location"], + project=event_attributes["project"], + params=params, + data=dataconnect_event_data, + ) + _core._with_init(func)(dataconnect_event) + + +@_util.copy_func_kwargs(DataConnectOptions) +def on_mutation_executed(**kwargs) -> _typing.Callable[[_C1], _C1]: + """ + Event handler that triggers when a mutation is executed in Firebase Data Connect. + + Example: + + .. code-block:: python + + @on_mutation_executed( + service = "service-id", + connector = "connector-id", + operation = "mutation-name" + ) + def mutation_executed_handler(event: Event[MutationEventData]): + pass + + :param \\*\\*kwargs: DataConnect options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.DataConnectOptions` + :rtype: :exc:`typing.Callable` + \\[ \\[ :exc:`firebase_functions.dataconnect_fn.Event` \\[ + :exc:`object` \\] \\], `None` \\] + A function that takes a DataConnect event and returns ``None``. + """ + options = DataConnectOptions(**kwargs) + + def on_mutation_executed_inner_decorator(func: _C1): + service_pattern = _path_pattern.PathPattern(options.service) + connector_pattern = _path_pattern.PathPattern(options.connector) + operation_pattern = _path_pattern.PathPattern(options.operation) + + @_functools.wraps(func) + def on_mutation_executed_wrapped(raw: _ce.CloudEvent): + return _dataconnect_endpoint_handler( + func, + _event_type_mutation_executed, + service_pattern, + connector_pattern, + operation_pattern, + raw, + ) + + _util.set_func_endpoint_attr( + on_mutation_executed_wrapped, + options._endpoint( + event_type=_event_type_mutation_executed, + func_name=func.__name__, + service_pattern=service_pattern, + connector_pattern=connector_pattern, + operation_pattern=operation_pattern, + ), + ) + return on_mutation_executed_wrapped + + return on_mutation_executed_inner_decorator diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index badf87e5..7c7ba1e4 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -1152,6 +1152,73 @@ def _endpoint( return _manifest.ManifestEndpoint(**_typing.cast(dict, kwargs_merged)) +@_dataclasses.dataclass(frozen=True, kw_only=True) +class DataConnectOptions(RuntimeOptions): + """ + Options specific to Firebase Data Connect function types. + Internal use only. + """ + + service: str + """ + The Firebase Data Connect service ID. + """ + + connector: str + """ + The Firebase Data Connect connector ID. + """ + + operation: str + """ + Name of the operation. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + assert kwargs["event_type"] is not None + assert kwargs["service_pattern"] is not None + assert kwargs["connector_pattern"] is not None + assert kwargs["operation_pattern"] is not None + + service_pattern: _path_pattern.PathPattern = kwargs["service_pattern"] + connector_pattern: _path_pattern.PathPattern = kwargs["connector_pattern"] + operation_pattern: _path_pattern.PathPattern = kwargs["operation_pattern"] + + event_filters: _typing.Any = {} + event_filters_path_patterns: _typing.Any = {} + + if service_pattern.has_wildcards: + event_filters_path_patterns["service"] = service_pattern.value + else: + event_filters["service"] = service_pattern.value + + if connector_pattern.has_wildcards: + event_filters_path_patterns["connector"] = connector_pattern.value + else: + event_filters["connector"] = connector_pattern.value + + if operation_pattern.has_wildcards: + event_filters_path_patterns["operation"] = operation_pattern.value + else: + event_filters["operation"] = operation_pattern.value + + event_trigger = _manifest.EventTrigger( + eventType=kwargs["event_type"], + retry=False, + eventFilters=event_filters, + eventFilterPathPatterns=event_filters_path_patterns, + ) + + kwargs_merged = { + **_dataclasses.asdict(super()._endpoint(**kwargs)), + "eventTrigger": event_trigger, + } + return _manifest.ManifestEndpoint(**_typing.cast(dict, kwargs_merged)) + + _GLOBAL_OPTIONS = RuntimeOptions() """The current default options for all functions. Internal use only.""" diff --git a/tests/test_dataconnect_fn.py b/tests/test_dataconnect_fn.py new file mode 100644 index 00000000..0b5170e4 --- /dev/null +++ b/tests/test_dataconnect_fn.py @@ -0,0 +1,127 @@ +""" +Tests for the dataconnect_fn module. +""" + +import json +import unittest +from unittest import mock + +from cloudevents.http import CloudEvent + +from firebase_functions import core, dataconnect_fn + + +class TestDataConnect(unittest.TestCase): + """ + Tests for the dataconnect_fn module. + """ + + def test_on_mutation_executed_decorator(self): + """ + Tests on_mutation_executed decorator functionality by checking that the + __firebase_endpoint__ attribute is set properly. + """ + func = mock.Mock(__name__="example_func") + decorated_func = dataconnect_fn.on_mutation_executed( + service="service-id", + connector="connector-id", + operation="mutation-name", + )(func) + endpoint = decorated_func.__firebase_endpoint__ + self.assertIsNotNone(endpoint) + self.assertIsNotNone(endpoint.eventTrigger) + self.assertEqual( + endpoint.eventTrigger["eventType"], + "google.firebase.dataconnect.connector.v1.mutationExecuted", + ) + self.assertIsNotNone(endpoint.eventTrigger["eventFilters"]) + self.assertEqual(endpoint.eventTrigger["eventFilters"]["service"], "service-id") + self.assertEqual(endpoint.eventTrigger["eventFilters"]["connector"], "connector-id") + self.assertEqual(endpoint.eventTrigger["eventFilters"]["operation"], "mutation-name") + + def test_on_mutation_executed_decorator_with_captures(self): + """ + Tests on_mutation_executed decorator functionality by checking that the + __firebase_endpoint__ attribute is set properly. + + Tests that captures are handled correctly. + """ + func = mock.Mock(__name__="example_func") + decorated_func = dataconnect_fn.on_mutation_executed( + service="{service}", + connector="{connector}", + operation="{operation}", + )(func) + endpoint = decorated_func.__firebase_endpoint__ + self.assertIsNotNone(endpoint) + self.assertIsNotNone(endpoint.eventTrigger) + self.assertEqual( + endpoint.eventTrigger["eventType"], + "google.firebase.dataconnect.connector.v1.mutationExecuted", + ) + self.assertIsNotNone(endpoint.eventTrigger["eventFilterPathPatterns"]) + self.assertEqual(endpoint.eventTrigger["eventFilterPathPatterns"]["service"], "{service}") + self.assertEqual( + endpoint.eventTrigger["eventFilterPathPatterns"]["connector"], "{connector}" + ) + self.assertEqual( + endpoint.eventTrigger["eventFilterPathPatterns"]["operation"], "{operation}" + ) + + def test_on_mutation_executed_decorator_with_wildcards(self): + """ + Tests on_mutation_executed decorator functionality by checking that the + __firebase_endpoint__ attribute is set properly. + + Tests that captures are handled correctly. + """ + func = mock.Mock(__name__="example_func") + decorated_func = dataconnect_fn.on_mutation_executed( + service="*", + connector="*", + operation="*", + )(func) + endpoint = decorated_func.__firebase_endpoint__ + self.assertIsNotNone(endpoint) + self.assertIsNotNone(endpoint.eventTrigger) + self.assertEqual( + endpoint.eventTrigger["eventType"], + "google.firebase.dataconnect.connector.v1.mutationExecuted", + ) + self.assertIsNotNone(endpoint.eventTrigger["eventFilterPathPatterns"]) + self.assertEqual(endpoint.eventTrigger["eventFilterPathPatterns"]["service"], "*") + self.assertEqual(endpoint.eventTrigger["eventFilterPathPatterns"]["connector"], "*") + self.assertEqual(endpoint.eventTrigger["eventFilterPathPatterns"]["operation"], "*") + + def test_calls_init_function(self): + hello = None + + @core.init + def init(): + nonlocal hello + hello = "world" + + event = CloudEvent( + attributes={ + "specversion": "1.0", + "id": "id", + "type": "google.firebase.dataconnect.connector.v1.mutationExecuted", + "source": "source", + "subject": "subject", + "time": "2024-04-10T12:00:00.000Z", + "project": "project-id", + "location": "location-id", + "service": "service-id", + "connector": "connector-id", + "operation": "mutation-name", + }, + data=json.dumps({}), + ) + + func = mock.Mock(__name__="example_func") + decorated_func = dataconnect_fn.on_mutation_executed( + service="service-id", connector="connector-id", operation="mutation-name" + )(func) + decorated_func(event) + + self.assertEqual(hello, "world") From 68a3d344347d546c960488de7fa19366a17441b8 Mon Sep 17 00:00:00 2001 From: Lisa Jian Date: Tue, 7 Oct 2025 12:27:28 -0700 Subject: [PATCH 2/6] Add auth context --- src/firebase_functions/dataconnect_fn.py | 15 +++++++++++++++ tests/test_dataconnect_fn.py | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py index 86b93c20..b4b1ec3b 100644 --- a/src/firebase_functions/dataconnect_fn.py +++ b/src/firebase_functions/dataconnect_fn.py @@ -30,6 +30,7 @@ _event_type_mutation_executed = "google.firebase.dataconnect.connector.v1.mutationExecuted" +AuthType = _typing.Literal["app_user", "admin", "unknown"] @_dataclass.dataclass(frozen=True) class Event(_core.CloudEvent[_core.T]): @@ -53,6 +54,15 @@ class Event(_core.CloudEvent[_core.T]): Only named capture groups are populated - {key}, {key=*}, {key=**} """ + auth_type: AuthType + """ + The type of principal that triggered the event. + """ + + auth_id: str + """ + The unique identifier for the principal. + """ @_dataclass.dataclass(frozen=True) class GraphqlErrorExtensions: @@ -210,6 +220,9 @@ def _dataconnect_endpoint_handler( **operation_pattern.extract_matches(event_operation), } + event_auth_type = event_attributes["authtype"] + event_auth_id = event_attributes["authid"] + dataconnect_event = Event( specversion=event_attributes["specversion"], id=event_attributes["id"], @@ -224,6 +237,8 @@ def _dataconnect_endpoint_handler( project=event_attributes["project"], params=params, data=dataconnect_event_data, + auth_type=event_auth_type, + auth_id=event_auth_id, ) _core._with_init(func)(dataconnect_event) diff --git a/tests/test_dataconnect_fn.py b/tests/test_dataconnect_fn.py index 0b5170e4..9df82199 100644 --- a/tests/test_dataconnect_fn.py +++ b/tests/test_dataconnect_fn.py @@ -114,6 +114,8 @@ def init(): "service": "service-id", "connector": "connector-id", "operation": "mutation-name", + "authtype": "app_user", + "authid": "auth-id" }, data=json.dumps({}), ) @@ -124,4 +126,12 @@ def init(): )(func) decorated_func(event) + func.assert_called_once() + event = func.call_args.args[0] + self.assertIsNotNone(event) + self.assertEqual(event.project, "project-id") + self.assertEqual(event.location, "location-id") + self.assertEqual(event.auth_type, "app_user") + self.assertEqual(event.auth_id, "auth-id") + self.assertEqual(hello, "world") From b7fcfadbb8063e930787c403a04360f19e47f94e Mon Sep 17 00:00:00 2001 From: Lisa Jian Date: Fri, 17 Oct 2025 13:11:06 -0700 Subject: [PATCH 3/6] Add FDC to generate docs --- docs/generate.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/generate.sh b/docs/generate.sh index 091776da..ee186df0 100755 --- a/docs/generate.sh +++ b/docs/generate.sh @@ -88,6 +88,7 @@ PY_MODULES='firebase_functions.core firebase_functions.alerts.billing_fn firebase_functions.alerts.crashlytics_fn firebase_functions.alerts.performance_fn + firebase_functions.dataconnect_fn firebase_functions.db_fn firebase_functions.eventarc_fn firebase_functions.firestore_fn From 1fc9a18bf7888bce21e6f0b42a73553553add3a8 Mon Sep 17 00:00:00 2001 From: Lisa Jian Date: Fri, 17 Oct 2025 20:57:16 -0700 Subject: [PATCH 4/6] Make dataconnect params optional --- src/firebase_functions/dataconnect_fn.py | 38 +++++++++++++++------- src/firebase_functions/options.py | 40 ++++++++++++------------ tests/test_dataconnect_fn.py | 17 ++++++++++ 3 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py index b4b1ec3b..bf6237e0 100644 --- a/src/firebase_functions/dataconnect_fn.py +++ b/src/firebase_functions/dataconnect_fn.py @@ -32,6 +32,7 @@ AuthType = _typing.Literal["app_user", "admin", "unknown"] + @_dataclass.dataclass(frozen=True) class Event(_core.CloudEvent[_core.T]): """ @@ -64,6 +65,7 @@ class Event(_core.CloudEvent[_core.T]): The unique identifier for the principal. """ + @_dataclass.dataclass(frozen=True) class GraphqlErrorExtensions: """ @@ -183,6 +185,7 @@ class Mutation: It conforms to https://spec.graphql.org/draft/#sec-Errors. """ + @_dataclass.dataclass(frozen=True) class MutationEventData: """ @@ -191,6 +194,7 @@ class MutationEventData: payload: Mutation + _E1 = Event[MutationEventData] _C1 = _typing.Callable[[_E1], None] @@ -198,9 +202,9 @@ class MutationEventData: def _dataconnect_endpoint_handler( func: _C1, event_type: str, - service_pattern: _path_pattern.PathPattern, - connector_pattern: _path_pattern.PathPattern, - operation_pattern: _path_pattern.PathPattern, + service_pattern: _path_pattern.PathPattern | None, + connector_pattern: _path_pattern.PathPattern | None, + operation_pattern: _path_pattern.PathPattern | None, raw: _ce.CloudEvent, ) -> None: # Currently, only mutationExecuted is supported @@ -214,11 +218,20 @@ def _dataconnect_endpoint_handler( event_service = event_attributes["service"] event_connector = event_attributes["connector"] event_operation = event_attributes["operation"] - params: dict[str, str] = { - **service_pattern.extract_matches(event_service), - **connector_pattern.extract_matches(event_connector), - **operation_pattern.extract_matches(event_operation), - } + params: dict[str, str] = {} + + if service_pattern: + params = {**params, **service_pattern.extract_matches(event_service)} + if connector_pattern: + params = { + **params, + **connector_pattern.extract_matches(event_connector) + } + if operation_pattern: + params = { + **params, + **operation_pattern.extract_matches(event_operation) + } event_auth_type = event_attributes["authtype"] event_auth_id = event_attributes["authid"] @@ -270,9 +283,12 @@ def mutation_executed_handler(event: Event[MutationEventData]): options = DataConnectOptions(**kwargs) def on_mutation_executed_inner_decorator(func: _C1): - service_pattern = _path_pattern.PathPattern(options.service) - connector_pattern = _path_pattern.PathPattern(options.connector) - operation_pattern = _path_pattern.PathPattern(options.operation) + service_pattern = _path_pattern.PathPattern( + options.service) if options.service else None + connector_pattern = _path_pattern.PathPattern( + options.connector) if options.connector else None + operation_pattern = _path_pattern.PathPattern( + options.operation) if options.operation else None @_functools.wraps(func) def on_mutation_executed_wrapped(raw: _ce.CloudEvent): diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 7c7ba1e4..c3ba16f0 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -1159,17 +1159,17 @@ class DataConnectOptions(RuntimeOptions): Internal use only. """ - service: str + service: str | None = None """ The Firebase Data Connect service ID. """ - connector: str + connector: str | None = None """ The Firebase Data Connect connector ID. """ - operation: str + operation: str | None = None """ Name of the operation. """ @@ -1179,9 +1179,6 @@ def _endpoint( **kwargs, ) -> _manifest.ManifestEndpoint: assert kwargs["event_type"] is not None - assert kwargs["service_pattern"] is not None - assert kwargs["connector_pattern"] is not None - assert kwargs["operation_pattern"] is not None service_pattern: _path_pattern.PathPattern = kwargs["service_pattern"] connector_pattern: _path_pattern.PathPattern = kwargs["connector_pattern"] @@ -1190,20 +1187,23 @@ def _endpoint( event_filters: _typing.Any = {} event_filters_path_patterns: _typing.Any = {} - if service_pattern.has_wildcards: - event_filters_path_patterns["service"] = service_pattern.value - else: - event_filters["service"] = service_pattern.value - - if connector_pattern.has_wildcards: - event_filters_path_patterns["connector"] = connector_pattern.value - else: - event_filters["connector"] = connector_pattern.value - - if operation_pattern.has_wildcards: - event_filters_path_patterns["operation"] = operation_pattern.value - else: - event_filters["operation"] = operation_pattern.value + if self.service: + if service_pattern.has_wildcards: + event_filters_path_patterns["service"] = service_pattern.value + else: + event_filters["service"] = service_pattern.value + + if self.connector: + if connector_pattern.has_wildcards: + event_filters_path_patterns["connector"] = connector_pattern.value + else: + event_filters["connector"] = connector_pattern.value + + if self.operation: + if operation_pattern.has_wildcards: + event_filters_path_patterns["operation"] = operation_pattern.value + else: + event_filters["operation"] = operation_pattern.value event_trigger = _manifest.EventTrigger( eventType=kwargs["event_type"], diff --git a/tests/test_dataconnect_fn.py b/tests/test_dataconnect_fn.py index 9df82199..6fb55f60 100644 --- a/tests/test_dataconnect_fn.py +++ b/tests/test_dataconnect_fn.py @@ -39,6 +39,23 @@ def test_on_mutation_executed_decorator(self): self.assertEqual(endpoint.eventTrigger["eventFilters"]["connector"], "connector-id") self.assertEqual(endpoint.eventTrigger["eventFilters"]["operation"], "mutation-name") + def test_on_mutation_executed_decorator_optional_filters(self): + """ + Tests on_mutation_executed decorator functionality by checking that the + __firebase_endpoint__ attribute is set properly. + """ + func = mock.Mock(__name__="example_func") + decorated_func = dataconnect_fn.on_mutation_executed()(func) + endpoint = decorated_func.__firebase_endpoint__ + self.assertIsNotNone(endpoint) + self.assertIsNotNone(endpoint.eventTrigger) + self.assertEqual( + endpoint.eventTrigger["eventType"], + "google.firebase.dataconnect.connector.v1.mutationExecuted", + ) + self.assertIsNotNone(endpoint.eventTrigger["eventFilters"]) + self.assertEqual(endpoint.eventTrigger["eventFilters"], {}) + def test_on_mutation_executed_decorator_with_captures(self): """ Tests on_mutation_executed decorator functionality by checking that the From 6b59255ad7248c018377931c1fb0a38fb49450c8 Mon Sep 17 00:00:00 2001 From: Lisa Jian Date: Mon, 20 Oct 2025 14:55:54 -0700 Subject: [PATCH 5/6] Fix time conversion --- src/firebase_functions/dataconnect_fn.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py index bf6237e0..5d601bb6 100644 --- a/src/firebase_functions/dataconnect_fn.py +++ b/src/firebase_functions/dataconnect_fn.py @@ -235,16 +235,14 @@ def _dataconnect_endpoint_handler( event_auth_type = event_attributes["authtype"] event_auth_id = event_attributes["authid"] + event_time = _util.timestamp_conversion(event_attributes["time"]) dataconnect_event = Event( specversion=event_attributes["specversion"], id=event_attributes["id"], source=event_attributes["source"], type=event_attributes["type"], - time=_dt.datetime.strptime( - event_attributes["time"], - "%Y-%m-%dT%H:%M:%S.%f%z", - ), + time=event_time, subject=event_attributes.get("subject"), location=event_attributes["location"], project=event_attributes["project"], From 68d2de201e8d2dae247d53dca214d15a58e58e5f Mon Sep 17 00:00:00 2001 From: Lisa Jian Date: Wed, 22 Oct 2025 14:27:03 -0700 Subject: [PATCH 6/6] Address PR feedback --- src/firebase_functions/dataconnect_fn.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py index 5d601bb6..d11eb9a6 100644 --- a/src/firebase_functions/dataconnect_fn.py +++ b/src/firebase_functions/dataconnect_fn.py @@ -208,7 +208,8 @@ def _dataconnect_endpoint_handler( raw: _ce.CloudEvent, ) -> None: # Currently, only mutationExecuted is supported - assert event_type == _event_type_mutation_executed + if event_type != _event_type_mutation_executed: + raise NotImplementedError(f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported.") event_attributes = raw._get_attributes() event_data: _typing.Any = raw.get_data()