diff --git a/docs/generate.sh b/docs/generate.sh index 43f0c9d..8e50357 100755 --- a/docs/generate.sh +++ b/docs/generate.sh @@ -85,6 +85,7 @@ TITLE="Firebase Python SDK for Cloud Functions" PY_MODULES='firebase_functions firebase_functions.core firebase_functions.db_fn + firebase_functions.eventarc_fn firebase_functions.https_fn firebase_functions.options firebase_functions.params diff --git a/samples/basic_eventarc/.firebaserc b/samples/basic_eventarc/.firebaserc new file mode 100644 index 0000000..ad27d4b --- /dev/null +++ b/samples/basic_eventarc/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "python-functions-testing" + } +} diff --git a/samples/basic_eventarc/.gitignore b/samples/basic_eventarc/.gitignore new file mode 100644 index 0000000..dbb58ff --- /dev/null +++ b/samples/basic_eventarc/.gitignore @@ -0,0 +1,66 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env diff --git a/samples/basic_eventarc/__init__.py b/samples/basic_eventarc/__init__.py new file mode 100644 index 0000000..2340b04 --- /dev/null +++ b/samples/basic_eventarc/__init__.py @@ -0,0 +1,3 @@ +# Required to avoid a 'duplicate modules' mypy error +# in monorepos that have multiple main.py files. +# https://github.com/python/mypy/issues/4008 diff --git a/samples/basic_eventarc/firebase.json b/samples/basic_eventarc/firebase.json new file mode 100644 index 0000000..7bbd899 --- /dev/null +++ b/samples/basic_eventarc/firebase.json @@ -0,0 +1,11 @@ +{ + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": [ + "venv" + ] + } + ] +} diff --git a/samples/basic_eventarc/functions/.gitignore b/samples/basic_eventarc/functions/.gitignore new file mode 100644 index 0000000..34cef6b --- /dev/null +++ b/samples/basic_eventarc/functions/.gitignore @@ -0,0 +1,13 @@ +# pyenv +.python-version + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Environments +.env +.venv +venv/ +venv.bak/ +__pycache__ diff --git a/samples/basic_eventarc/functions/main.py b/samples/basic_eventarc/functions/main.py new file mode 100644 index 0000000..6f716fa --- /dev/null +++ b/samples/basic_eventarc/functions/main.py @@ -0,0 +1,12 @@ +"""Firebase Cloud Functions for Eventarc triggers example.""" +from firebase_functions import eventarc_fn + + +@eventarc_fn.on_custom_event_published( + event_type="firebase.extensions.storage-resize-images.v1.complete",) +def onimageresize(event: eventarc_fn.CloudEvent) -> None: + """ + Handle image resize events from the Firebase Storage Resize Images extension. + https://extensions.dev/extensions/firebase/storage-resize-images + """ + print("Received image resize completed event", event) diff --git a/samples/basic_eventarc/functions/requirements.txt b/samples/basic_eventarc/functions/requirements.txt new file mode 100644 index 0000000..8977a41 --- /dev/null +++ b/samples/basic_eventarc/functions/requirements.txt @@ -0,0 +1,8 @@ +# Not published yet, +# firebase-functions-python >= 0.0.1 +# so we use a relative path during development: +./../../../ +# Or switch to git ref for deployment testing: +# git+https://github.com/firebase/firebase-functions-python.git@main#egg=firebase-functions + +firebase-admin >= 6.0.1 diff --git a/src/firebase_functions/eventarc_fn.py b/src/firebase_functions/eventarc_fn.py new file mode 100644 index 0000000..d3b62f3 --- /dev/null +++ b/src/firebase_functions/eventarc_fn.py @@ -0,0 +1,88 @@ +# 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. +"""Cloud functions to handle Eventarc events.""" + +# pylint: disable=protected-access +import typing as _typing +import functools as _functools +import datetime as _dt +import cloudevents.http as _ce + +import firebase_functions.options as _options +import firebase_functions.private.util as _util +from firebase_functions.core import CloudEvent + + +@_util.copy_func_kwargs(_options.EventarcTriggerOptions) +def on_custom_event_published( + **kwargs +) -> _typing.Callable[[_typing.Callable[[CloudEvent], None]], _typing.Callable[ + [CloudEvent], None]]: + """ + Creates a handler for events published on the default event eventarc channel. + + Example: + + .. code-block:: python + + from firebase_functions import eventarc_fn + + @eventarc_fn.on_custom_event_published( + event_type="firebase.extensions.storage-resize-images.v1.complete", + ) + def onimageresize(event: eventarc_fn.CloudEvent) -> None: + pass + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.EventarcTriggerOptions` + :rtype: :exc:`typing.Callable` + \\[ \\[ :exc:`firebase_functions.core.CloudEvent` \\], `None` \\] + A function that takes a CloudEvent and returns None. + """ + options = _options.EventarcTriggerOptions(**kwargs) + + def on_custom_event_published_decorator(func: _typing.Callable[[CloudEvent], + None]): + + @_functools.wraps(func) + def on_custom_event_published_wrapped(raw: _ce.CloudEvent): + event_attributes = raw._get_attributes() + event_data: _typing.Any = raw.get_data() + event_dict = {**event_data, **event_attributes} + event: CloudEvent = CloudEvent( + data=event_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.set_func_endpoint_attr( + on_custom_event_published_wrapped, + options._endpoint(func_name=func.__name__), + ) + _util.set_required_apis_attr( + on_custom_event_published_wrapped, + options._required_apis(), + ) + return on_custom_event_published_wrapped + + return on_custom_event_published_decorator diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 6d05215..50c3c6e 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -502,6 +502,66 @@ def _endpoint( **kwargs, event_filters=event_filters, event_type=event_type)))) +@_dataclasses.dataclass(frozen=True, kw_only=True) +class EventarcTriggerOptions(EventHandlerOptions): + """ + Options that can be set on an Eventarc trigger. + Internal use only. + """ + + event_type: str + """ + Type of the event to trigger on. + """ + + channel: str | None = None + """ + ID of the channel. Can be either: + * fully qualified channel resource name: + `projects/{project}/locations/{location}/channels/{channel-id}` + * partial resource name with location and channel ID, in which case + the runtime project ID of the function will be used: + `locations/{location}/channels/{channel-id}` + * partial channel ID, in which case the runtime project ID of the + function and `us-central1` as location will be used: + `{channel-id}` + + If not specified, the default Firebase channel will be used: + `projects/{project}/locations/us-central1/channels/firebase` + """ + + filters: dict[str, str] | None = None + """ + Eventarc event exact match filter. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + event_filters = {} if self.filters is None else self.filters + endpoint = _manifest.ManifestEndpoint(**_typing.cast( + _typing.Dict, + _dataclasses.asdict(super()._endpoint( + **kwargs, + event_filters=event_filters, + event_type=self.event_type, + )))) + assert endpoint.eventTrigger is not None + channel = (self.channel if self.channel is not None else + "locations/us-central1/channels/firebase") + endpoint.eventTrigger["channel"] = channel + return endpoint + + def _required_apis(self) -> list[_manifest.ManifestRequiredApi]: + return [ + _manifest.ManifestRequiredApi( + api="eventarcpublishing.googleapis.com", + reason="Needed for custom event functions", + ) + ] + + @_dataclasses.dataclass(frozen=True, kw_only=True) class ScheduleOptions(RuntimeOptions): """ diff --git a/tests/test_eventarc_fn.py b/tests/test_eventarc_fn.py new file mode 100644 index 0000000..882a6d1 --- /dev/null +++ b/tests/test_eventarc_fn.py @@ -0,0 +1,85 @@ +# 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. +"""Eventarc trigger function tests.""" +import unittest +from unittest.mock import Mock +from cloudevents.http import CloudEvent as _CloudEvent +from firebase_functions.core import CloudEvent +from firebase_functions.eventarc_fn import on_custom_event_published + + +class TestEventarcFn(unittest.TestCase): + """ + Test Eventarc trigger functions. + """ + + def test_on_custom_event_published_decorator(self): + """ + Tests the on_custom_event_published decorator functionality by checking + that the __firebase_endpoint__ attribute is set properly. + """ + func = Mock(__name__="example_func") + + decorated_func = on_custom_event_published( + event_type="firebase.extensions.storage-resize-images.v1.complete", + )(func) + + endpoint = getattr(decorated_func, "__firebase_endpoint__") + self.assertIsNotNone(endpoint) + self.assertIsNotNone(endpoint.eventTrigger) + self.assertEqual( + endpoint.eventTrigger["eventType"], + "firebase.extensions.storage-resize-images.v1.complete", + ) + + def test_on_custom_event_published_wrapped(self): + """ + Tests the wrapped function created by the on_custom_event_published + decorator, ensuring that it correctly processes the raw event and calls + the user-provided function with a properly formatted CloudEvent instance. + """ + func = Mock(__name__="example_func") + raw_event = _CloudEvent( + attributes={ + "specversion": "1.0", + "type": "firebase.extensions.storage-resize-images.v1.complete", + "source": "https://example.com/testevent", + "id": "1234567890", + "subject": "test_subject", + "time": "2023-03-11T13:25:37.403Z", + }, + data={ + "some_key": "some_value", + }, + ) + + decorated_func = on_custom_event_published( + event_type="firebase.extensions.storage-resize-images.v1.complete", + )(func) + + decorated_func(raw_event) + + func.assert_called_once() + + event_arg = func.call_args.args[0] + self.assertIsInstance(event_arg, CloudEvent) + self.assertEqual(event_arg.data, {"some_key": "some_value"}) + self.assertEqual(event_arg.id, "1234567890") + self.assertEqual(event_arg.source, "https://example.com/testevent") + self.assertEqual(event_arg.specversion, "1.0") + self.assertEqual(event_arg.subject, "test_subject") + self.assertEqual( + event_arg.type, + "firebase.extensions.storage-resize-images.v1.complete", + )