diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 21f44e2..967c7cc 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -437,34 +437,30 @@ def _required_apis(self) -> list[_manifest.ManifestRequiredApi]: ] +# TODO refactor Storage & Database options to use this base class. @_dataclasses.dataclass(frozen=True, kw_only=True) -class PubSubOptions(RuntimeOptions): +class EventHandlerOptions(RuntimeOptions): """ - Options specific to Pub/Sub function types. + Options specific to any event handling Cloud function. Internal use only. """ - retry: bool | None = None + retry: bool | Expression[bool] | _util.Sentinel | None = None """ Whether failed executions should be delivered again. """ - topic: str - """ - The Pub/Sub topic to watch for message events. - """ - def _endpoint( self, **kwargs, ) -> _manifest.ManifestEndpoint: - event_filters: _typing.Any = { - "topic": self.topic, - } + assert kwargs["event_filters"] is not None + assert kwargs["event_type"] is not None + event_trigger = _manifest.EventTrigger( - eventType="google.cloud.pubsub.topic.v1.messagePublished", - retry=False, - eventFilters=event_filters, + eventType=kwargs["event_type"], + retry=self.retry if self.retry is not None else False, + eventFilters=kwargs["event_filters"], ) kwargs_merged = { @@ -476,6 +472,32 @@ def _endpoint( **_typing.cast(_typing.Dict, kwargs_merged)) +@_dataclasses.dataclass(frozen=True, kw_only=True) +class PubSubOptions(EventHandlerOptions): + """ + Options specific to Pub/Sub function types. + Internal use only. + """ + + topic: str + """ + The Pub/Sub topic to watch for message events. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + event_filters: _typing.Any = { + "topic": self.topic, + } + event_type = "google.cloud.pubsub.topic.v1.messagePublished" + return _manifest.ManifestEndpoint(**_typing.cast( + _typing.Dict, + _dataclasses.asdict(super()._endpoint( + **kwargs, event_filters=event_filters, event_type=event_type)))) + + @_dataclasses.dataclass(frozen=True, kw_only=True) class StorageOptions(RuntimeOptions): """ diff --git a/src/firebase_functions/private/manifest.py b/src/firebase_functions/private/manifest.py index c2b0140..6617911 100644 --- a/src/firebase_functions/private/manifest.py +++ b/src/firebase_functions/private/manifest.py @@ -61,7 +61,8 @@ class EventTrigger(_typing.TypedDict): str, str | _params.Expression[str]]] channel: _typing_extensions.NotRequired[str] eventType: _typing_extensions.Required[str] - retry: _typing_extensions.Required[bool | _params.Expression[bool]] + retry: _typing_extensions.Required[bool | _params.Expression[bool] | + _util.Sentinel] class RetryConfig(_typing.TypedDict): diff --git a/src/firebase_functions/pubsub_fn.py b/src/firebase_functions/pubsub_fn.py index b92abff..8a4c313 100644 --- a/src/firebase_functions/pubsub_fn.py +++ b/src/firebase_functions/pubsub_fn.py @@ -171,7 +171,7 @@ def example(event: CloudEvent[MessagePublishedData[object]]) -> None: :type \\*\\*kwargs: as :exc:`firebase_functions.options.PubSubOptions` :rtype: :exc:`typing.Callable` \\[ \\[ :exc:`firebase_functions.core.CloudEvent` \\[ - :exc:`firebase_functions.pubsub.MessagePublishedData` \\[ + :exc:`firebase_functions.pubsub_fn.MessagePublishedData` \\[ :exc:`typing.Any` \\] \\] \\], `None` \\] A function that takes a CloudEvent and returns None. """ diff --git a/src/firebase_functions/remote_config_fn.py b/src/firebase_functions/remote_config_fn.py new file mode 100644 index 0000000..6f64497 --- /dev/null +++ b/src/firebase_functions/remote_config_fn.py @@ -0,0 +1,228 @@ +# 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. +# pylint: disable=protected-access +""" +Cloud functions to handle Remote Config events. +""" +import dataclasses as _dataclasses +import functools as _functools +import datetime as _dt +import typing as _typing +import cloudevents.http as _ce +import enum as _enum + +import firebase_functions.private.util as _util + +from firebase_functions.core import CloudEvent +from firebase_functions.options import EventHandlerOptions + + +@_dataclasses.dataclass(frozen=True) +class ConfigUser: + """ + All the fields associated with the person/service account that wrote a Remote Config template. + """ + + name: str + """ + Display name. + """ + + email: str + """ + Email address. + """ + + image_url: str + """ + Image URL. + """ + + +class ConfigUpdateOrigin(str, _enum.Enum): + """ + Where the Remote Config update action originated. + """ + + REMOTE_CONFIG_UPDATE_ORIGIN_UNSPECIFIED = "REMOTE_CONFIG_UPDATE_ORIGIN_UNSPECIFIED" + """ + Catch-all for unrecognized values. + """ + + CONSOLE = "CONSOLE" + """ + The update came from the Firebase UI. + """ + + REST_API = "REST_API" + """ + The update came from the Remote Config REST API. + """ + + ADMIN_SDK_NODE = "ADMIN_SDK_NODE" + """ + The update came from the Firebase Admin Node SDK. + """ + + +class ConfigUpdateType(str, _enum.Enum): + """ + What type of update was associated with the Remote Config template version. + """ + + REMOTE_CONFIG_UPDATE_TYPE_UNSPECIFIED = "REMOTE_CONFIG_UPDATE_TYPE_UNSPECIFIED" + """ + Catch-all for unrecognized enum values. + """ + + INCREMENTAL_UPDATE = "INCREMENTAL_UPDATE" + """ + A regular incremental update. + """ + + FORCED_UPDATE = "FORCED_UPDATE" + """ + A forced update. The ETag was specified as "*" in an UpdateRemoteConfigRequest + request or the "Force Update" button was pressed on the console. + """ + + ROLLBACK = "ROLLBACK" + """ + A rollback to a previous Remote Config template. + """ + + +@_dataclasses.dataclass(frozen=True) +class ConfigUpdateData: + """ + The data within Firebase Remote Config update events. + """ + + version_number: int + """ + The version number of the version's corresponding Remote Config template. + """ + + update_time: _dt.datetime + """ + When the Remote Config template was written to the Remote Config server. + """ + + update_user: ConfigUser + """ + Aggregation of all metadata fields about the account that performed the update. + """ + + description: str + """ + The user-provided description of the corresponding Remote Config template. + """ + + update_origin: ConfigUpdateOrigin + """ + Where the update action originated. + """ + + update_type: ConfigUpdateType + """ + What type of update was made. + """ + + rollback_source: int | None = None + """ + Only present if this version is the result of a rollback, and will be + the version number of the Remote Config template that was rolled-back to. + """ + + +_E1 = CloudEvent[ConfigUpdateData] +_C1 = _typing.Callable[[_E1], None] + + +def _config_handler(func: _C1, raw: _ce.CloudEvent) -> None: + event_attributes = raw._get_attributes() + event_data: _typing.Any = raw.get_data() + event_dict = {**event_data, **event_attributes} + + config_data = ConfigUpdateData( + version_number=event_data["versionNumber"], + update_time=_dt.datetime.strptime(event_data["updateTime"], + "%Y-%m-%dT%H:%M:%S.%f%z"), + update_user=ConfigUser( + name=event_data["updateUser"]["name"], + email=event_data["updateUser"]["email"], + image_url=event_data["updateUser"]["imageUrl"], + ), + description=event_data["description"], + update_origin=ConfigUpdateOrigin(event_data["updateOrigin"]), + update_type=ConfigUpdateType(event_data["updateType"]), + rollback_source=event_data.get("rollbackSource", None), + ) + + event: CloudEvent[ConfigUpdateData] = CloudEvent( + data=config_data, + id=event_dict["id"], + source=event_dict["source"], + specversion=event_dict["specversion"], + subject=event_dict["subject"] if "subject" in event_dict else None, + time=_dt.datetime.strptime( + event_dict["time"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ), + type=event_dict["type"], + ) + + func(event) + + +@_util.copy_func_kwargs(EventHandlerOptions) +def on_config_updated(**kwargs) -> _typing.Callable[[_C1], _C1]: + """ + Event handler which triggers when data is updated in a Remote Config. + + Example: + + .. code-block:: python + + @on_config_updated() + def example(event: CloudEvent[ConfigUpdateData]) -> None: + pass + + :param \\*\\*kwargs: Pub/Sub options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.EventHandlerOptions` + :rtype: :exc:`typing.Callable` + \\[ \\[ :exc:`firebase_functions.core.CloudEvent` \\[ + :exc:`firebase_functions.remote_config_fn.ConfigUpdateData` \\[ + :exc:`typing.Any` \\] \\] \\], `None` \\] + A function that takes a CloudEvent and returns None. + """ + options = EventHandlerOptions(**kwargs) + + def on_config_updated_inner_decorator(func: _C1): + + @_functools.wraps(func) + def on_config_updated_wrapped(raw: _ce.CloudEvent): + return _config_handler(func, raw) + + _util.set_func_endpoint_attr( + on_config_updated_wrapped, + options._endpoint( + func_name=func.__name__, + event_filters={}, + event_type="google.firebase.remoteconfig.remoteConfig.v1.updated" + ), + ) + return on_config_updated_wrapped + + return on_config_updated_inner_decorator diff --git a/tests/test_pubsub_fn.py b/tests/test_pubsub_fn.py new file mode 100644 index 0000000..06777d8 --- /dev/null +++ b/tests/test_pubsub_fn.py @@ -0,0 +1,96 @@ +# 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. +"""PubSub function tests.""" +import unittest +import datetime as _dt +from unittest.mock import MagicMock +from cloudevents.http import CloudEvent as _CloudEvent + +from firebase_functions.pubsub_fn import ( + Message, + MessagePublishedData, + on_message_published, + _message_handler, + CloudEvent, +) + + +class TestPubSub(unittest.TestCase): + """ + PubSub function tests. + """ + + def test_on_message_published_decorator(self): + """ + Tests the on_message_published decorator functionality by checking that + the _endpoint attribute is set properly. + """ + func = MagicMock() + func.__name__ = "testfn" + decorated_func = on_message_published(topic="hello-world")(func) + endpoint = getattr(decorated_func, "__firebase_endpoint__") + self.assertIsNotNone(endpoint) + self.assertIsNotNone(endpoint.eventTrigger) + self.assertIsNotNone(endpoint.eventTrigger["eventType"]) + self.assertEqual("hello-world", + endpoint.eventTrigger["eventFilters"]["topic"]) + + def test_message_handler(self): + """ + Tests the _message_handler function, ensuring that it correctly processes + the raw event and calls the user-provided function with a properly + formatted CloudEvent instance. + """ + func = MagicMock() + raw_event = _CloudEvent( + attributes={ + "id": "test-message", + "source": "https://example.com/pubsub", + "specversion": "1.0", + "time": "2023-03-11T13:25:37.403Z", + "type": "com.example.pubsub.message", + }, + data={ + "message": { + "attributes": { + "key": "value" + }, + # {"test": "value"} + "data": "eyJ0ZXN0IjogInZhbHVlIn0=", + "message_id": "message-id-123", + "publish_time": "2023-03-11T13:25:37.403Z", + }, + "subscription": "my-subscription", + }, + ) + + _message_handler(func, raw_event) + func.assert_called_once() + event_arg = func.call_args.args[0] + self.assertIsInstance(event_arg, CloudEvent) + self.assertIsInstance(event_arg.data, MessagePublishedData) + self.assertIsInstance(event_arg.data.message, Message) + self.assertEqual(event_arg.data.message.message_id, "message-id-123") + self.assertEqual( + event_arg.data.message.publish_time, + _dt.datetime.strptime( + "2023-03-11T13:25:37.403Z", + "%Y-%m-%dT%H:%M:%S.%f%z", + )) + self.assertDictEqual(event_arg.data.message.attributes, + {"key": "value"}) + self.assertEqual(event_arg.data.message.data, + "eyJ0ZXN0IjogInZhbHVlIn0=") + self.assertIsNone(event_arg.data.message.ordering_key) + self.assertEqual(event_arg.data.subscription, "my-subscription") diff --git a/tests/test_remote_config_fn.py b/tests/test_remote_config_fn.py new file mode 100644 index 0000000..2854a0a --- /dev/null +++ b/tests/test_remote_config_fn.py @@ -0,0 +1,90 @@ +# 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. +"""Remote Config function tests.""" +import unittest +from unittest.mock import MagicMock +from cloudevents.http import CloudEvent as _CloudEvent + +from firebase_functions.remote_config_fn import ( + CloudEvent, + ConfigUser, + ConfigUpdateData, + ConfigUpdateOrigin, + ConfigUpdateType, + on_config_updated, + _config_handler, +) + + +class TestRemoteConfig(unittest.TestCase): + """ + Remote Config function tests. + """ + + def test_on_config_updated_decorator(self): + """ + Tests the on_config_updated decorator functionality by checking + that the __firebase_endpoint__ attribute is set properly. + """ + func = MagicMock() + func.__name__ = "testfn" + decorated_func = on_config_updated()(func) + endpoint = getattr(decorated_func, "__firebase_endpoint__") + self.assertIsNotNone(endpoint) + self.assertIsNotNone(endpoint.eventTrigger) + self.assertIsNotNone(endpoint.eventTrigger["eventType"]) + + def test_config_handler(self): + """ + Tests the _config_handler function, ensuring that it correctly processes + the raw event and calls the user-provided function with a properly + formatted CloudEvent instance. + """ + func = MagicMock() + raw_event = _CloudEvent( + attributes={ + "specversion": "1.0", + "type": "com.example.someevent", + "source": "https://example.com/someevent", + "id": "A234-1234-1234", + "time": "2023-03-11T13:25:37.403Z", + }, + data={ + "versionNumber": 42, + "updateTime": "2023-03-11T13:25:37.403Z", + "updateUser": { + "name": "John Doe", + "email": "johndoe@example.com", + "imageUrl": "https://example.com/image.jpg" + }, + "description": "Test update", + "updateOrigin": "CONSOLE", + "updateType": "INCREMENTAL_UPDATE", + "rollbackSource": 41 + }) + + _config_handler(func, raw_event) + + func.assert_called_once() + + event_arg = func.call_args.args[0] + self.assertIsInstance(event_arg, CloudEvent) + self.assertIsInstance(event_arg.data, ConfigUpdateData) + self.assertIsInstance(event_arg.data.update_user, ConfigUser) + self.assertEqual(event_arg.data.version_number, 42) + self.assertEqual(event_arg.data.update_origin, + ConfigUpdateOrigin.CONSOLE) + self.assertEqual(event_arg.data.update_type, + ConfigUpdateType.INCREMENTAL_UPDATE) + self.assertEqual(event_arg.data.rollback_source, 41)