Skip to content

Commit 365b9c8

Browse files
authored
Merge pull request #61 from invertase/eventarc
feat: eventarc function support
2 parents 0f497ce + 7724e8e commit 365b9c8

File tree

11 files changed

+352
-0
lines changed

11 files changed

+352
-0
lines changed

docs/generate.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ TITLE="Firebase Python SDK for Cloud Functions"
8585
PY_MODULES='firebase_functions
8686
firebase_functions.core
8787
firebase_functions.db_fn
88+
firebase_functions.eventarc_fn
8889
firebase_functions.https_fn
8990
firebase_functions.options
9091
firebase_functions.params

samples/basic_eventarc/.firebaserc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"projects": {
3+
"default": "python-functions-testing"
4+
}
5+
}

samples/basic_eventarc/.gitignore

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
firebase-debug.log*
8+
firebase-debug.*.log*
9+
10+
# Firebase cache
11+
.firebase/
12+
13+
# Firebase config
14+
15+
# Uncomment this if you'd like others to create their own Firebase project.
16+
# For a team working on the same Firebase project(s), it is recommended to leave
17+
# it commented so all members can deploy to the same project(s) in .firebaserc.
18+
# .firebaserc
19+
20+
# Runtime data
21+
pids
22+
*.pid
23+
*.seed
24+
*.pid.lock
25+
26+
# Directory for instrumented libs generated by jscoverage/JSCover
27+
lib-cov
28+
29+
# Coverage directory used by tools like istanbul
30+
coverage
31+
32+
# nyc test coverage
33+
.nyc_output
34+
35+
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
36+
.grunt
37+
38+
# Bower dependency directory (https://bower.io/)
39+
bower_components
40+
41+
# node-waf configuration
42+
.lock-wscript
43+
44+
# Compiled binary addons (http://nodejs.org/api/addons.html)
45+
build/Release
46+
47+
# Dependency directories
48+
node_modules/
49+
50+
# Optional npm cache directory
51+
.npm
52+
53+
# Optional eslint cache
54+
.eslintcache
55+
56+
# Optional REPL history
57+
.node_repl_history
58+
59+
# Output of 'npm pack'
60+
*.tgz
61+
62+
# Yarn Integrity file
63+
.yarn-integrity
64+
65+
# dotenv environment variables file
66+
.env

samples/basic_eventarc/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Required to avoid a 'duplicate modules' mypy error
2+
# in monorepos that have multiple main.py files.
3+
# https://github.com/python/mypy/issues/4008

samples/basic_eventarc/firebase.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"functions": [
3+
{
4+
"source": "functions",
5+
"codebase": "default",
6+
"ignore": [
7+
"venv"
8+
]
9+
}
10+
]
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# pyenv
2+
.python-version
3+
4+
# Installer logs
5+
pip-log.txt
6+
pip-delete-this-directory.txt
7+
8+
# Environments
9+
.env
10+
.venv
11+
venv/
12+
venv.bak/
13+
__pycache__
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Firebase Cloud Functions for Eventarc triggers example."""
2+
from firebase_functions import eventarc_fn
3+
4+
5+
@eventarc_fn.on_custom_event_published(
6+
event_type="firebase.extensions.storage-resize-images.v1.complete",)
7+
def onimageresize(event: eventarc_fn.CloudEvent) -> None:
8+
"""
9+
Handle image resize events from the Firebase Storage Resize Images extension.
10+
https://extensions.dev/extensions/firebase/storage-resize-images
11+
"""
12+
print("Received image resize completed event", event)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Not published yet,
2+
# firebase-functions-python >= 0.0.1
3+
# so we use a relative path during development:
4+
./../../../
5+
# Or switch to git ref for deployment testing:
6+
# git+https://github.com/firebase/firebase-functions-python.git@main#egg=firebase-functions
7+
8+
firebase-admin >= 6.0.1

src/firebase_functions/eventarc_fn.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Copyright 2022 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Cloud functions to handle Eventarc events."""
15+
16+
# pylint: disable=protected-access
17+
import typing as _typing
18+
import functools as _functools
19+
import datetime as _dt
20+
import cloudevents.http as _ce
21+
22+
import firebase_functions.options as _options
23+
import firebase_functions.private.util as _util
24+
from firebase_functions.core import CloudEvent
25+
26+
27+
@_util.copy_func_kwargs(_options.EventarcTriggerOptions)
28+
def on_custom_event_published(
29+
**kwargs
30+
) -> _typing.Callable[[_typing.Callable[[CloudEvent], None]], _typing.Callable[
31+
[CloudEvent], None]]:
32+
"""
33+
Creates a handler for events published on the default event eventarc channel.
34+
35+
Example:
36+
37+
.. code-block:: python
38+
39+
from firebase_functions import eventarc_fn
40+
41+
@eventarc_fn.on_custom_event_published(
42+
event_type="firebase.extensions.storage-resize-images.v1.complete",
43+
)
44+
def onimageresize(event: eventarc_fn.CloudEvent) -> None:
45+
pass
46+
47+
:param \\*\\*kwargs: Options.
48+
:type \\*\\*kwargs: as :exc:`firebase_functions.options.EventarcTriggerOptions`
49+
:rtype: :exc:`typing.Callable`
50+
\\[ \\[ :exc:`firebase_functions.core.CloudEvent` \\], `None` \\]
51+
A function that takes a CloudEvent and returns None.
52+
"""
53+
options = _options.EventarcTriggerOptions(**kwargs)
54+
55+
def on_custom_event_published_decorator(func: _typing.Callable[[CloudEvent],
56+
None]):
57+
58+
@_functools.wraps(func)
59+
def on_custom_event_published_wrapped(raw: _ce.CloudEvent):
60+
event_attributes = raw._get_attributes()
61+
event_data: _typing.Any = raw.get_data()
62+
event_dict = {**event_data, **event_attributes}
63+
event: CloudEvent = CloudEvent(
64+
data=event_data,
65+
id=event_dict["id"],
66+
source=event_dict["source"],
67+
specversion=event_dict["specversion"],
68+
subject=event_dict["subject"]
69+
if "subject" in event_dict else None,
70+
time=_dt.datetime.strptime(
71+
event_dict["time"],
72+
"%Y-%m-%dT%H:%M:%S.%f%z",
73+
),
74+
type=event_dict["type"],
75+
)
76+
func(event)
77+
78+
_util.set_func_endpoint_attr(
79+
on_custom_event_published_wrapped,
80+
options._endpoint(func_name=func.__name__),
81+
)
82+
_util.set_required_apis_attr(
83+
on_custom_event_published_wrapped,
84+
options._required_apis(),
85+
)
86+
return on_custom_event_published_wrapped
87+
88+
return on_custom_event_published_decorator

src/firebase_functions/options.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,66 @@ def _endpoint(
502502
**kwargs, event_filters=event_filters, event_type=event_type))))
503503

504504

505+
@_dataclasses.dataclass(frozen=True, kw_only=True)
506+
class EventarcTriggerOptions(EventHandlerOptions):
507+
"""
508+
Options that can be set on an Eventarc trigger.
509+
Internal use only.
510+
"""
511+
512+
event_type: str
513+
"""
514+
Type of the event to trigger on.
515+
"""
516+
517+
channel: str | None = None
518+
"""
519+
ID of the channel. Can be either:
520+
* fully qualified channel resource name:
521+
`projects/{project}/locations/{location}/channels/{channel-id}`
522+
* partial resource name with location and channel ID, in which case
523+
the runtime project ID of the function will be used:
524+
`locations/{location}/channels/{channel-id}`
525+
* partial channel ID, in which case the runtime project ID of the
526+
function and `us-central1` as location will be used:
527+
`{channel-id}`
528+
529+
If not specified, the default Firebase channel will be used:
530+
`projects/{project}/locations/us-central1/channels/firebase`
531+
"""
532+
533+
filters: dict[str, str] | None = None
534+
"""
535+
Eventarc event exact match filter.
536+
"""
537+
538+
def _endpoint(
539+
self,
540+
**kwargs,
541+
) -> _manifest.ManifestEndpoint:
542+
event_filters = {} if self.filters is None else self.filters
543+
endpoint = _manifest.ManifestEndpoint(**_typing.cast(
544+
_typing.Dict,
545+
_dataclasses.asdict(super()._endpoint(
546+
**kwargs,
547+
event_filters=event_filters,
548+
event_type=self.event_type,
549+
))))
550+
assert endpoint.eventTrigger is not None
551+
channel = (self.channel if self.channel is not None else
552+
"locations/us-central1/channels/firebase")
553+
endpoint.eventTrigger["channel"] = channel
554+
return endpoint
555+
556+
def _required_apis(self) -> list[_manifest.ManifestRequiredApi]:
557+
return [
558+
_manifest.ManifestRequiredApi(
559+
api="eventarcpublishing.googleapis.com",
560+
reason="Needed for custom event functions",
561+
)
562+
]
563+
564+
505565
@_dataclasses.dataclass(frozen=True, kw_only=True)
506566
class ScheduleOptions(RuntimeOptions):
507567
"""

tests/test_eventarc_fn.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright 2022 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Eventarc trigger function tests."""
15+
import unittest
16+
from unittest.mock import Mock
17+
from cloudevents.http import CloudEvent as _CloudEvent
18+
from firebase_functions.core import CloudEvent
19+
from firebase_functions.eventarc_fn import on_custom_event_published
20+
21+
22+
class TestEventarcFn(unittest.TestCase):
23+
"""
24+
Test Eventarc trigger functions.
25+
"""
26+
27+
def test_on_custom_event_published_decorator(self):
28+
"""
29+
Tests the on_custom_event_published decorator functionality by checking
30+
that the __firebase_endpoint__ attribute is set properly.
31+
"""
32+
func = Mock(__name__="example_func")
33+
34+
decorated_func = on_custom_event_published(
35+
event_type="firebase.extensions.storage-resize-images.v1.complete",
36+
)(func)
37+
38+
endpoint = getattr(decorated_func, "__firebase_endpoint__")
39+
self.assertIsNotNone(endpoint)
40+
self.assertIsNotNone(endpoint.eventTrigger)
41+
self.assertEqual(
42+
endpoint.eventTrigger["eventType"],
43+
"firebase.extensions.storage-resize-images.v1.complete",
44+
)
45+
46+
def test_on_custom_event_published_wrapped(self):
47+
"""
48+
Tests the wrapped function created by the on_custom_event_published
49+
decorator, ensuring that it correctly processes the raw event and calls
50+
the user-provided function with a properly formatted CloudEvent instance.
51+
"""
52+
func = Mock(__name__="example_func")
53+
raw_event = _CloudEvent(
54+
attributes={
55+
"specversion": "1.0",
56+
"type": "firebase.extensions.storage-resize-images.v1.complete",
57+
"source": "https://example.com/testevent",
58+
"id": "1234567890",
59+
"subject": "test_subject",
60+
"time": "2023-03-11T13:25:37.403Z",
61+
},
62+
data={
63+
"some_key": "some_value",
64+
},
65+
)
66+
67+
decorated_func = on_custom_event_published(
68+
event_type="firebase.extensions.storage-resize-images.v1.complete",
69+
)(func)
70+
71+
decorated_func(raw_event)
72+
73+
func.assert_called_once()
74+
75+
event_arg = func.call_args.args[0]
76+
self.assertIsInstance(event_arg, CloudEvent)
77+
self.assertEqual(event_arg.data, {"some_key": "some_value"})
78+
self.assertEqual(event_arg.id, "1234567890")
79+
self.assertEqual(event_arg.source, "https://example.com/testevent")
80+
self.assertEqual(event_arg.specversion, "1.0")
81+
self.assertEqual(event_arg.subject, "test_subject")
82+
self.assertEqual(
83+
event_arg.type,
84+
"firebase.extensions.storage-resize-images.v1.complete",
85+
)

0 commit comments

Comments
 (0)