Skip to content

Commit ae6ed75

Browse files
committed
feat: remote config function types
1 parent 98d4bfd commit ae6ed75

File tree

5 files changed

+433
-15
lines changed

5 files changed

+433
-15
lines changed

src/firebase_functions/options.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -318,34 +318,30 @@ def convert_secret(
318318
return endpoint
319319

320320

321+
# TODO refactor Storage & Database options to use this base class.
321322
@_dataclasses.dataclass(frozen=True, kw_only=True)
322-
class PubSubOptions(RuntimeOptions):
323+
class EventHandlerOptions(RuntimeOptions):
323324
"""
324-
Options specific to Pub/Sub function types.
325+
Options specific to any event handling Cloud function.
325326
Internal use only.
326327
"""
327328

328-
retry: bool | None = None
329+
retry: bool | Expression[bool] | _util.Sentinel | None = None
329330
"""
330331
Whether failed executions should be delivered again.
331332
"""
332333

333-
topic: str
334-
"""
335-
The Pub/Sub topic to watch for message events.
336-
"""
337-
338334
def _endpoint(
339335
self,
340336
**kwargs,
341337
) -> _manifest.ManifestEndpoint:
342-
event_filters: _typing.Any = {
343-
"topic": self.topic,
344-
}
338+
assert kwargs["event_filters"] is not None
339+
assert kwargs["event_type"] is not None
340+
345341
event_trigger = _manifest.EventTrigger(
346-
eventType="google.cloud.pubsub.topic.v1.messagePublished",
347-
retry=False,
348-
eventFilters=event_filters,
342+
eventType=kwargs["event_type"],
343+
retry=self.retry if self.retry is not None else False,
344+
eventFilters=kwargs["event_filters"],
349345
)
350346

351347
kwargs_merged = {
@@ -357,6 +353,32 @@ def _endpoint(
357353
**_typing.cast(_typing.Dict, kwargs_merged))
358354

359355

356+
@_dataclasses.dataclass(frozen=True, kw_only=True)
357+
class PubSubOptions(EventHandlerOptions):
358+
"""
359+
Options specific to Pub/Sub function types.
360+
Internal use only.
361+
"""
362+
363+
topic: str
364+
"""
365+
The Pub/Sub topic to watch for message events.
366+
"""
367+
368+
def _endpoint(
369+
self,
370+
**kwargs,
371+
) -> _manifest.ManifestEndpoint:
372+
event_filters: _typing.Any = {
373+
"topic": self.topic,
374+
}
375+
event_type = "google.cloud.pubsub.topic.v1.messagePublished"
376+
return _manifest.ManifestEndpoint(**_typing.cast(
377+
_typing.Dict,
378+
_dataclasses.asdict(super()._endpoint(
379+
**kwargs, event_filters=event_filters, event_type=event_type))))
380+
381+
360382
@_dataclasses.dataclass(frozen=True, kw_only=True)
361383
class StorageOptions(RuntimeOptions):
362384
"""

src/firebase_functions/private/manifest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ class EventTrigger(_typing.TypedDict):
6161
str, str | _params.Expression[str]]]
6262
channel: _typing_extensions.NotRequired[str]
6363
eventType: _typing_extensions.Required[str]
64-
retry: _typing_extensions.Required[bool | _params.Expression[bool]]
64+
retry: _typing_extensions.Required[bool | _params.Expression[bool] |
65+
_util.Sentinel]
6566

6667

6768
class RetryConfig(_typing.TypedDict):
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
# pylint: disable=protected-access
15+
"""
16+
Cloud functions to handle Remote Config events.
17+
"""
18+
import dataclasses as _dataclasses
19+
import functools as _functools
20+
import datetime as _dt
21+
import typing as _typing
22+
import cloudevents.http as _ce
23+
import enum as _enum
24+
25+
import firebase_functions.private.util as _util
26+
27+
from firebase_functions.core import CloudEvent
28+
from firebase_functions.options import EventHandlerOptions
29+
30+
31+
@_dataclasses.dataclass(frozen=True)
32+
class ConfigUser:
33+
"""
34+
All the fields associated with the person/service account that wrote a Remote Config template.
35+
"""
36+
37+
name: str
38+
"""
39+
Display name.
40+
"""
41+
42+
email: str
43+
"""
44+
Email address.
45+
"""
46+
47+
image_url: str
48+
"""
49+
Image URL.
50+
"""
51+
52+
53+
class ConfigUpdateOrigin(str, _enum.Enum):
54+
"""
55+
Where the Remote Config update action originated.
56+
"""
57+
58+
REMOTE_CONFIG_UPDATE_ORIGIN_UNSPECIFIED = "REMOTE_CONFIG_UPDATE_ORIGIN_UNSPECIFIED"
59+
"""
60+
Catch-all for unrecognized values.
61+
"""
62+
63+
CONSOLE = "CONSOLE"
64+
"""
65+
The update came from the Firebase UI.
66+
"""
67+
68+
REST_API = "REST_API"
69+
"""
70+
The update came from the Remote Config REST API.
71+
"""
72+
73+
ADMIN_SDK_NODE = "ADMIN_SDK_NODE"
74+
"""
75+
The update came from the Firebase Admin Node SDK.
76+
"""
77+
78+
79+
class ConfigUpdateType(str, _enum.Enum):
80+
"""
81+
What type of update was associated with the Remote Config template version.
82+
"""
83+
84+
REMOTE_CONFIG_UPDATE_TYPE_UNSPECIFIED = "REMOTE_CONFIG_UPDATE_TYPE_UNSPECIFIED"
85+
"""
86+
Catch-all for unrecognized enum values.
87+
"""
88+
89+
INCREMENTAL_UPDATE = "INCREMENTAL_UPDATE"
90+
"""
91+
A regular incremental update.
92+
"""
93+
94+
FORCED_UPDATE = "FORCED_UPDATE"
95+
"""
96+
A forced update. The ETag was specified as "*" in an UpdateRemoteConfigRequest
97+
request or the "Force Update" button was pressed on the console.
98+
"""
99+
100+
ROLLBACK = "ROLLBACK"
101+
"""
102+
A rollback to a previous Remote Config template.
103+
"""
104+
105+
106+
@_dataclasses.dataclass(frozen=True)
107+
class ConfigUpdateData:
108+
"""
109+
The data within Firebase Remote Config update events.
110+
"""
111+
112+
version_number: int
113+
"""
114+
The version number of the version's corresponding Remote Config template.
115+
"""
116+
117+
update_time: _dt.datetime
118+
"""
119+
When the Remote Config template was written to the Remote Config server.
120+
"""
121+
122+
update_user: ConfigUser
123+
"""
124+
Aggregation of all metadata fields about the account that performed the update.
125+
"""
126+
127+
description: str
128+
"""
129+
The user-provided description of the corresponding Remote Config template.
130+
"""
131+
132+
update_origin: ConfigUpdateOrigin
133+
"""
134+
Where the update action originated.
135+
"""
136+
137+
update_type: ConfigUpdateType
138+
"""
139+
What type of update was made.
140+
"""
141+
142+
rollback_source: int | None = None
143+
"""
144+
Only present if this version is the result of a rollback, and will be
145+
the version number of the Remote Config template that was rolled-back to.
146+
"""
147+
148+
149+
_E1 = CloudEvent[ConfigUpdateData]
150+
_C1 = _typing.Callable[[_E1], None]
151+
152+
153+
def _config_handler(func: _C1, raw: _ce.CloudEvent) -> None:
154+
event_attributes = raw._get_attributes()
155+
event_data: _typing.Any = raw.get_data()
156+
event_dict = {**event_data, **event_attributes}
157+
158+
config_data = ConfigUpdateData(
159+
version_number=event_data["versionNumber"],
160+
update_time=_dt.datetime.strptime(event_data["updateTime"],
161+
"%Y-%m-%dT%H:%M:%S.%f%z"),
162+
update_user=ConfigUser(
163+
name=event_data["updateUser"]["name"],
164+
email=event_data["updateUser"]["email"],
165+
image_url=event_data["updateUser"]["imageUrl"],
166+
),
167+
description=event_data["description"],
168+
update_origin=ConfigUpdateOrigin(event_data["updateOrigin"]),
169+
update_type=ConfigUpdateType(event_data["updateType"]),
170+
rollback_source=event_data.get("rollbackSource", None),
171+
)
172+
173+
event: CloudEvent[ConfigUpdateData] = CloudEvent(
174+
data=config_data,
175+
id=event_dict["id"],
176+
source=event_dict["source"],
177+
specversion=event_dict["specversion"],
178+
subject=event_dict["subject"] if "subject" in event_dict else None,
179+
time=_dt.datetime.strptime(
180+
event_dict["time"],
181+
"%Y-%m-%dT%H:%M:%S.%f%z",
182+
),
183+
type=event_dict["type"],
184+
)
185+
186+
func(event)
187+
188+
189+
@_util.copy_func_kwargs(EventHandlerOptions)
190+
def on_config_updated(**kwargs) -> _typing.Callable[[_C1], _C1]:
191+
options = EventHandlerOptions(**kwargs)
192+
193+
def on_config_updated_inner_decorator(func: _C1):
194+
195+
@_functools.wraps(func)
196+
def on_config_updated_wrapped(raw: _ce.CloudEvent):
197+
return _config_handler(func, raw)
198+
199+
_util.set_func_endpoint_attr(
200+
on_config_updated_wrapped,
201+
options._endpoint(
202+
func_name=func.__name__,
203+
event_filters={},
204+
event_type="google.firebase.remoteconfig.remoteConfig.v1.updated"
205+
),
206+
)
207+
return on_config_updated_wrapped
208+
209+
return on_config_updated_inner_decorator

tests/test_pubsub_fn.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
"""PubSub function tests."""
15+
import unittest
16+
import datetime as _dt
17+
from unittest.mock import MagicMock
18+
from cloudevents.http import CloudEvent as _CloudEvent
19+
20+
from firebase_functions.pubsub_fn import (
21+
Message,
22+
MessagePublishedData,
23+
on_message_published,
24+
_message_handler,
25+
CloudEvent,
26+
)
27+
28+
29+
class TestPubSub(unittest.TestCase):
30+
"""
31+
PubSub function tests.
32+
"""
33+
34+
def test_on_message_published_decorator(self):
35+
"""
36+
Tests the on_message_published decorator functionality by checking that
37+
the _endpoint attribute is set properly.
38+
"""
39+
func = MagicMock()
40+
func.__name__ = "testfn"
41+
decorated_func = on_message_published(topic="hello-world")(func)
42+
endpoint = getattr(decorated_func, "__firebase_endpoint__")
43+
self.assertIsNotNone(endpoint)
44+
self.assertIsNotNone(endpoint.eventTrigger)
45+
self.assertIsNotNone(endpoint.eventTrigger["eventType"])
46+
self.assertEqual("hello-world",
47+
endpoint.eventTrigger["eventFilters"]["topic"])
48+
49+
def test_message_handler(self):
50+
"""
51+
Tests the _message_handler function, ensuring that it correctly processes
52+
the raw event and calls the user-provided function with a properly
53+
formatted CloudEvent instance.
54+
"""
55+
func = MagicMock()
56+
raw_event = _CloudEvent(
57+
attributes={
58+
"id": "test-message",
59+
"source": "https://example.com/pubsub",
60+
"specversion": "1.0",
61+
"time": "2023-03-11T13:25:37.403Z",
62+
"type": "com.example.pubsub.message",
63+
},
64+
data={
65+
"message": {
66+
"attributes": {
67+
"key": "value"
68+
},
69+
# {"test": "value"}
70+
"data": "eyJ0ZXN0IjogInZhbHVlIn0=",
71+
"message_id": "message-id-123",
72+
"publish_time": "2023-03-11T13:25:37.403Z",
73+
},
74+
"subscription": "my-subscription",
75+
},
76+
)
77+
78+
_message_handler(func, raw_event)
79+
func.assert_called_once()
80+
event_arg = func.call_args.args[0]
81+
self.assertIsInstance(event_arg, CloudEvent)
82+
self.assertIsInstance(event_arg.data, MessagePublishedData)
83+
self.assertIsInstance(event_arg.data.message, Message)
84+
self.assertEqual(event_arg.data.message.message_id, "message-id-123")
85+
self.assertEqual(
86+
event_arg.data.message.publish_time,
87+
_dt.datetime.strptime(
88+
"2023-03-11T13:25:37.403Z",
89+
"%Y-%m-%dT%H:%M:%S.%f%z",
90+
))
91+
self.assertDictEqual(event_arg.data.message.attributes,
92+
{"key": "value"})
93+
self.assertEqual(event_arg.data.message.data,
94+
"eyJ0ZXN0IjogInZhbHVlIn0=")
95+
self.assertIsNone(event_arg.data.message.ordering_key)
96+
self.assertEqual(event_arg.data.subscription, "my-subscription")

0 commit comments

Comments
 (0)